Timeout 超时控制
要点
- 超时控制防止单个请求占用过长的处理时间,拖慢整个服务
- Hono 内置
timeout()中间件基于AbortController实现 - 超时后中间件抛出
AbortError,需要在onError里捕获并返回合适的响应 - 下游调用(数据库、外部 API)需要接收
signal才能响应超时 - 流式响应(SSE、chunked transfer)需要特殊的超时策略
- 超时时间是服务级配置,不同路由可能需要不同的阈值
1. 基本用法
timeout() 中间件给每个请求设置最大处理时间:
// src/index.ts
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
const app = new Hono()
// 所有请求最多处理 5 秒
app.use('*', timeout(5000))
app.get('/fast', (c) => c.text('fast response'))
app.get('/slow', async (c) => {
// 模拟慢操作
await new Promise((resolve) => setTimeout(resolve, 10000))
return c.text('slow response')
})
export default app访问 /slow 时,5 秒后中间件抛出超时错误。
2. 处理超时错误
超时后 timeout() 抛出 Error,需要在 app.onError() 里捕获:
// src/index.ts
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
const app = new Hono()
app.use('*', timeout(5000))
app.onError((err, c) => {
if (err.name === 'AbortError' || err.message === 'Timeout') {
console.log('Request timeout:', c.req.path)
return c.json({ error: 'Request timeout' }, 504)
}
return c.json({ error: 'Internal server error' }, 500)
})
app.get('/', (c) => c.text('Hello'))
export default app504 Gateway Timeout 是适合超时场景的状态码。如果用 500,客户端无法区分是服务端崩溃还是超时。
3. 传递 signal 给下游调用
timeout() 中间件在 context 上设置 AbortSignal。下游调用(数据库、fetch)需要接收这个 signal 才能真正响应超时:
// src/index.ts
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
const app = new Hono()
app.use('*', timeout(5000))
app.get('/api/data', async (c) => {
// 从 context 获取 signal
const signal = c.req.raw.signal
// 传递给 fetch
const res = await fetch('https://slow-api.example.com/data', { signal })
const data = await res.json()
return c.json(data)
})
export default app如果不把 signal 传递给 fetch(),即使超时了,fetch 仍然会继续等待响应。中间件返回超时错误后,fetch 的响应可能稍后才到达,但已经没有意义。
数据库调用也需要接收 signal:
// Prisma 示例
app.get('/users', async (c) => {
const signal = c.req.raw.signal
const users = await prisma.user.findMany({
// Prisma 不直接支持 signal,但可以用 $executeRaw 或封装超时逻辑
})
return c.json(users)
})不同 ORM 和数据库驱动对 signal 的支持程度不同。如果 ORM 不支持 signal,可以在超时后取消响应,但数据库查询仍然会执行完成(浪费资源)。
4. 自定义超时时间
不同路由可能需要不同的超时时间。读接口通常比写接口更短:
// src/index.ts
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
const app = new Hono()
// 全局:10 秒
app.use('*', timeout(10000))
// 读接口:3 秒(覆盖全局)
app.use('/api/users/*', timeout(3000))
// 写接口:15 秒
app.use('/api/import/*', timeout(15000))
// 流式接口:不设置超时
app.get('/api/stream', (c) => {
// 不使用 timeout 中间件
return c.text('streaming...')
})
export default app注意:Hono 的中间件按注册顺序执行。如果全局和路径级都挂了 timeout(),后注册的会覆盖先注册的(取决于中间件的实现方式)。如果行为不符合预期,需要测试确认。
5. 流式响应与超时
流式响应(SSE、chunked transfer)需要特殊的超时策略。timeout() 中间件统计的是从请求到达直到响应完成的总时间,对于长时间运行的流式响应,总时间可能超过设定的超时。
两种处理方式:
5.1 给流式响应单独设置更长的超时
app.get('/api/stream', timeout(300000), (c) => { // 5 分钟
return c.stream(async (stream) => {
for (let i = 0; i < 100; i++) {
await stream.write(`data: chunk ${i}\n\n`)
await new Promise((resolve) => setTimeout(resolve, 1000))
}
})
})5.2 不用 timeout,改用其他机制
对于 SSE 长连接,可以不用 timeout(),而是依赖客户端断连或心跳机制:
app.get('/api/events', async (c) => {
const signal = c.req.raw.signal
return c.stream(async (stream) => {
// 客户端断开时 signal 会 abort
signal.addEventListener('abort', () => {
console.log('Client disconnected')
})
while (!signal.aborted) {
await stream.write(`data: ${Date.now()}\n\n`)
await new Promise((resolve) => setTimeout(resolve, 1000))
}
})
})6. 与 fetch 配合的超时
timeout() 中间件处理的是服务端超时。客户端调用 API 时,也需要设置超时:
// 客户端代码
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch('https://api.example.com/data', {
signal: controller.signal,
})
const data = await res.json()
} catch (err) {
if (err.name === 'AbortError') {
console.log('Client-side timeout')
}
} finally {
clearTimeout(timeoutId)
}客户端超时和服务端超时是独立的。客户端 5 秒超时后断开连接,服务端可能还在处理请求(直到服务端超时)。
7. 超时的级联影响
上游服务的超时如果设置不当,会产生级联影响:
- 客户端超时 5 秒
- API 网关超时 10 秒
- 后端服务超时 30 秒
- 数据库查询超时 60 秒
如果客户端 5 秒超时后断开连接,但后端服务还在等待数据库响应(最长 60 秒),这段时间内后端的资源被占用,无法服务其他请求。
推荐的做法是超时时间从外到内递增:
客户端 (5s) < API 网关 (10s) < 后端服务 (15s) < 数据库 (30s)或者让外层超时稍大于内层,给内层留出返回错误的缓冲时间:
数据库 (10s) < 后端服务 (12s) < API 网关 (15s) < 客户端 (18s)8. 超时与重试
客户端收到 504 后是否应该重试?取决于请求的幂等性:
- GET 请求:幂等,可以安全重试
- POST/PUT/DELETE:非幂等,重试可能导致重复操作
幂等请求的超时重试:
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(url, { ...options, signal: controller.signal })
clearTimeout(timeoutId)
if (res.status !== 504 && res.status !== 503) {
return res
}
// 指数退避
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, i)))
} catch (err) {
clearTimeout(timeoutId)
if (err.name !== 'AbortError') throw err
}
}
throw new Error('Max retries exceeded')
}指数退避(exponential backoff)避免在服务端过载时加剧问题。
9. 测试超时
// tests/timeout.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
describe('timeout middleware', () => {
it('超时后返回 504', async () => {
const app = new Hono()
app.use('*', timeout(100)) // 100ms 超时
app.get('/slow', async (c) => {
await new Promise((resolve) => setTimeout(resolve, 500))
return c.text('done')
})
app.onError((err, c) => {
if (err.name === 'AbortError') {
return c.json({ error: 'timeout' }, 504)
}
return c.json({ error: 'error' }, 500)
})
const res = await app.request('/slow')
expect(res.status).toBe(504)
})
it('未超时时正常返回', async () => {
const app = new Hono()
app.use('*', timeout(1000))
app.get('/fast', (c) => c.text('ok'))
const res = await app.request('/fast')
expect(res.status).toBe(200)
})
})总结
超时控制是保护服务稳定性的重要机制。Hono 内置的 timeout() 中间件基于 AbortController 实现,给每个请求设置最大处理时间。
这一节涉及到的几个层次:
- 基本用法:
timeout(ms)设置全局超时 - 错误处理:在
onError里捕获AbortError,返回 504 - signal 传递:下游调用(fetch、数据库)需要接收 signal 才能真正响应超时
- 路由差异化:不同路由可以配置不同的超时时间
- 流式响应:需要特殊的超时策略,或不用 timeout
- 级联影响:超时时间从外到内递增,避免资源浪费
- 超时与重试:幂等请求可以重试,使用指数退避
超时配置需要根据实际业务特性和下游依赖调整。过于激进的超时会频繁失败,过于宽松则无法防护慢请求拖慢整个服务。
下一篇看缓存中间件——cache() 中间件的 Cache-Control、ETag、缓存失效策略。