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 app

504 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. 超时的级联影响

上游服务的超时如果设置不当,会产生级联影响:

  1. 客户端超时 5 秒
  2. API 网关超时 10 秒
  3. 后端服务超时 30 秒
  4. 数据库查询超时 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 实现,给每个请求设置最大处理时间。

这一节涉及到的几个层次:

  1. 基本用法timeout(ms) 设置全局超时
  2. 错误处理:在 onError 里捕获 AbortError,返回 504
  3. signal 传递:下游调用(fetch、数据库)需要接收 signal 才能真正响应超时
  4. 路由差异化:不同路由可以配置不同的超时时间
  5. 流式响应:需要特殊的超时策略,或不用 timeout
  6. 级联影响:超时时间从外到内递增,避免资源浪费
  7. 超时与重试:幂等请求可以重试,使用指数退避

超时配置需要根据实际业务特性和下游依赖调整。过于激进的超时会频繁失败,过于宽松则无法防护慢请求拖慢整个服务。

下一篇看缓存中间件——cache() 中间件的 Cache-Control、ETag、缓存失效策略。