超时控制

要点

  • AI API 的响应时间波动大,短则一两秒,长则几十秒,不做超时控制就等于把主动权交给了上游
  • 超时控制的核心机制是 AbortController + setTimeoutfetch 原生支持通过 signal 取消请求
  • 在 Hono 里可以写一个全局中间件,给每个请求设置统一的超时上限
  • 流式响应的超时处理方式不同——需要设置「空闲超时」,而不是从头到尾一个固定时间
  • 超时之后不只是返回错误,还要清理已分配的资源:abort 子请求、关闭流、避免写入不完整数据
  • 超时时间不应该硬编码在代码里,集中放在配置模块,按接口类型和环境区分

1. 为什么需要超时控制

先看一个真实场景。你用 fetch 调 OpenAI 的接口,正常情况 2-5 秒返回结果。但某天上游服务出了问题,某个请求 30 秒都没有响应。

如果没有超时控制,会发生什么:

  1. 你的 Worker 一直在等,占着一个执行上下文
  2. Cloudflare Workers 有 CPU time 上限,等到触发平台限制就会被强制终止
  3. 用户那边一直转圈,体验很差
  4. 用户可能已经重试了好几次,产生了多笔重复调用,而上游还在慢吞吞地处理

AI API 的响应时间比普通 REST API 波动大得多。同一个模型,短回答可能 1 秒,长回答可能 20 秒,遇到上游过载,30 秒以上也不罕见。你没法用一个固定的「正常耗时」来判断请求是不是卡住了。

所以需要一个机制:给每个请求设一个时间上限,超时就主动取消,返回错误

2. 超时控制的核心:AbortController

AbortController 是取消异步操作的统一接口。基本用法分三步:

  1. 创建一个 AbortController 实例
  2. 把它的 signal 传给需要取消的操作(比如 fetch
  3. 在合适的时候调用 controller.abort(),操作就会中止
// basic-abort.ts
const controller = new AbortController()
 
// 把 signal 传给 fetch
const promise = fetch('https://api.example.com/data', {
  signal: controller.signal,
})
 
// 5 秒后如果还没完成,主动取消
setTimeout(() => controller.abort(), 5000)
 
try {
  const res = await promise
  const data = await res.json()
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    console.log('请求超时,已取消')
  } else {
    throw error
  }
}

几个细节:

  • controller.abort() 调用之后,fetch 的 Promise 会 reject,抛出 AbortError
  • 判断是不是超时导致的取消,检查 error.name === 'AbortError' 就够了
  • AbortController 是一次性的——abort 之后不能「恢复」,只能重新创建

3. 封装一个可复用的 fetch 超时函数

每次请求都手写 AbortController + setTimeout 太啰嗦。封装一个工具函数:

// src/utils/fetch-with-timeout.ts
export async function fetchWithTimeout(
  url: string | URL,
  options: RequestInit & { timeout?: number } = {}
): Promise<Response> {
  const { timeout = 10000, signal: externalSignal, ...rest } = options
  const controller = new AbortController()
 
  // 外部取消时,同步 abort 内部的 controller
  externalSignal?.addEventListener('abort', () => controller.abort())
 
  const timer = setTimeout(() => controller.abort(), timeout)
 
  try {
    return await fetch(url, { ...rest, signal: controller.signal })
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      throw new TimeoutError(`请求 ${url} 超时(${timeout}ms)`)
    }
    throw error
  } finally {
    clearTimeout(timer)
  }
}
 
class TimeoutError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'TimeoutError'
  }
}

三个设计点:

  • 串联外部 signal — 调用方可能也需要取消请求(比如用户关了页面)。监听外部 signal 的 abort 事件,同步触发内部取消
  • 自定义 TimeoutError — 原生 AbortError 信息太少。带上 URL 和超时时间,排查问题时能直接定位
  • finally 清理 timer — 不管请求成功还是失败,clearTimeout 都要执行,避免高并发下积累未清理的定时器

4. Hono 中间件实现全局超时

fetchWithTimeout 解决的是「单个出站请求」的超时。但 Hono 服务端还需要一层保护:如果某个路由的整体处理时间过长,需要一个兜底。

// src/middleware/timeout.ts
import { createMiddleware } from 'hono/factory'
 
export const timeoutMiddleware = createMiddleware<{
  Variables: { abortSignal: AbortSignal }
}>(async (c, next) => {
  const durationMs = 25000 // 默认 25 秒
  const controller = new AbortController()
  c.set('abortSignal', controller.signal)
 
  const timer = setTimeout(() => controller.abort(), durationMs)
 
  try {
    await Promise.race([
      next(),
      new Promise<void>((_, reject) => {
        controller.signal.addEventListener('abort', () => {
          reject(new Error(`请求处理超时(${durationMs}ms)`))
        })
      }),
    ])
  } catch (error) {
    if (error instanceof Error && error.message.startsWith('请求处理超时')) {
      return c.json({ error: 'Request Timeout', message: error.message }, 408)
    }
    throw error
  } finally {
    clearTimeout(timer)
  }
})

使用的时候,在入口文件挂载:

// src/index.ts
import { Hono } from 'hono'
import { timeoutMiddleware } from './middleware/timeout'
 
const app = new Hono()
app.use('*', timeoutMiddleware)
 
// 路由里通过 c.get('abortSignal') 拿到 signal,传给下游 fetch
app.post('/api/chat', async (c) => {
  const signal = c.get('abortSignal')
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'gpt-4', messages: [...] }),
    signal,
  })
  return c.json(await res.json())
})

关键设计:中间件把 abortSignal 通过 c.set() 注入请求上下文。路由和下游工具函数都能拿到同一个 signal,超时触发时所有子操作都会收到取消通知。

5. 按路由设置不同的超时时间

不同类型的接口,合理的超时时间差异很大:

  • 普通 CRUD 接口:1-3 秒
  • 调用 LLM 的非流式接口:15-30 秒
  • 流式接口:可能持续几十秒甚至几分钟

一个全局固定值不够。在中间件里支持按路由配置:

// src/middleware/timeout.ts
const ROUTE_TIMEOUTS: Record<string, number> = {
  '/api/chat/stream': 120000, // 流式:2 分钟
  '/api/chat': 30000,         // LLM 非流式:30 秒
  '/api/health': 5000,        // 健康检查:5 秒
}
const DEFAULT_TIMEOUT_MS = 25000
 
function getTimeoutForPath(path: string): number {
  if (ROUTE_TIMEOUTS[path]) return ROUTE_TIMEOUTS[path]
  for (const [prefix, timeout] of Object.entries(ROUTE_TIMEOUTS)) {
    if (path.startsWith(prefix)) return timeout
  }
  return DEFAULT_TIMEOUT_MS
}

setTimeout(() => controller.abort(), durationMs) 里的 durationMs 换成 getTimeoutForPath(c.req.path) 就行。精确匹配优先,前缀匹配兜底。

6. 流式响应中的超时处理

非流式接口的超时很直观:从请求发出到收到完整响应,超过 N 秒就取消。但流式接口不一样——数据分块到达,第一个字节可能 2 秒就到了,后面每隔几百毫秒来一块,整个过程可能持续几分钟。用一个固定的总超时,要么设太短截断正常响应,要么设太长起不到保护作用。

对流式接口更有意义的指标是空闲超时:超过 N 秒没收到新数据,就认为连接出了问题,主动断开。

// src/utils/stream-with-idle-timeout.ts
const IDLE_TIMEOUT_MS = 30000
 
export function streamWithIdleTimeout<T>(
  source: ReadableStream<T>,
  idleTimeoutMs: number = IDLE_TIMEOUT_MS
): ReadableStream<T> {
  const reader = source.getReader()
  let idleTimer: ReturnType<typeof setTimeout> | null = null
  let isDone = false
 
  function resetIdleTimer() {
    if (idleTimer) clearTimeout(idleTimer)
    idleTimer = setTimeout(() => reader.cancel().catch(() => {}), idleTimeoutMs)
  }
 
  return new ReadableStream<T>({
    async pull(controller) {
      if (isDone) { controller.close(); return }
      resetIdleTimer()
      try {
        const { value, done } = await reader.read()
        if (done) {
          isDone = true
          if (idleTimer) clearTimeout(idleTimer)
          controller.close()
          return
        }
        controller.enqueue(value)
      } catch (error) {
        isDone = true
        if (idleTimer) clearTimeout(idleTimer)
        controller.error(error)
      }
    },
    cancel() {
      isDone = true
      if (idleTimer) clearTimeout(idleTimer)
      reader.cancel().catch(() => {})
    },
  })
}

在路由里使用:

// src/routes/chat-stream.ts
app.post('/api/chat/stream', async (c) => {
  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { Authorization: `Bearer ${c.env.OPENAI_API_KEY}` },
    body: JSON.stringify({ stream: true, messages: [...] }),
  })
 
  if (!upstream.body) return c.json({ error: 'No response body' }, 502)
 
  const safeStream = streamWithIdleTimeout(upstream.body)
  return new Response(safeStream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })
})

有一个容易忽略的点:pullreader.read() 是一个 Promise。如果在等待过程中触发了空闲超时,reader.cancel() 会让 reader.read() reject,进入 catch 分支。超时检测和流读取异常处理自然汇合到同一个地方。

7. 超时后的资源清理

超时取消不只是返回一个错误就完了。如果不清理已分配的资源,可能会造成内存泄漏或连接池耗尽。

子请求

传给下游 fetchsignal 会在 abort 时自动取消对应的请求,不需要额外处理。

数据库查询

Drizzle + D1 的查询每次独立,不存在连接池问题。但如果你用 TCP 连接其他数据库,abort 之后需要确认连接被归还:

// src/utils/query-with-timeout.ts
export async function queryWithTimeout<T>(
  queryFn: (signal: AbortSignal) => Promise<T>,
  signal: AbortSignal,
  timeoutMs: number
): Promise<T> {
  const controller = new AbortController()
  signal.addEventListener('abort', () => controller.abort())
  const timer = setTimeout(() => controller.abort(), timeoutMs)
  try {
    return await queryFn(controller.signal)
  } finally {
    clearTimeout(timer)
  }
}

流式响应

streamWithIdleTimeoutcancel 回调已经通过 reader.cancel() 关闭了上游的流。但路由层如果超时触发时正在向客户端推送数据,还需要用 Promise.race 确保能提前退出:

// src/routes/chat-stream.ts
app.post('/api/chat/stream', async (c) => {
  const signal = c.get('abortSignal')
  const abortPromise = new Promise<never>((_, reject) => {
    signal.addEventListener('abort', () => reject(new Error('请求超时')))
  })
 
  try {
    const upstream = await Promise.race([
      fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: { Authorization: `Bearer ${c.env.OPENAI_API_KEY}` },
        body: JSON.stringify({ stream: true, messages: [...] }),
        signal,
      }),
      abortPromise,
    ])
    // ... 正常处理流
  } catch {
    return c.json({ error: 'Request Timeout' }, 408)
  }
})

KV 写入

超时发生时如果已经读到了部分数据,KV 里可能存了一条不完整的数据。解决办法是等流完整结束后再写:

// 等流完整结束后再写 KV,避免写入不完整数据
let fullContent = ''
for await (const chunk of stream) {
  fullContent += chunk
  // 推给客户端
}
await c.env.KV.put(`response:${sessionId}`, fullContent)

8. 超时配置管理

超时时间分散在各个文件里硬编码,时间长了很难维护。三个原则:

集中导出

所有超时配置放到一个文件,其他模块从这里导入:

// src/config/timeout.ts
export const TIMEOUT = {
  DEFAULT: 25000,           // 普通请求
  LLM: 30000,              // LLM 非流式
  LLM_STREAM_IDLE: 30000,  // LLM 流式空闲超时
  HEALTH: 5000,            // 健康检查
  DB_QUERY: 5000,          // 数据库查询
  KV: 3000,                // KV 读写
} as const

按环境区分

开发环境可以设长一些(方便调试),线上按实际 SLA 来设:

// src/config/timeout.ts
export function getTimeouts(env: Env) {
  const isDev = env.APP_ENV === 'development'
  return {
    default: isDev ? 60000 : TIMEOUT.DEFAULT,
    llm: isDev ? 120000 : TIMEOUT.LLM,
    llmStreamIdle: isDev ? 120000 : TIMEOUT.LLM_STREAM_IDLE,
  }
}

环境变量动态调整

线上紧急情况下不用改代码、不用重新部署,通过环境变量就能调整:

// wrangler.jsonc
{ "vars": { "TIMEOUT_LLM": "45000", "TIMEOUT_LLM_STREAM_IDLE": "60000" } }
// src/config/timeout.ts
export function resolveTimeout(env: Env, key: string, fallback: number): number {
  const raw = (env as any)[`TIMEOUT_${key}`]
  const parsed = raw ? parseInt(raw, 10) : NaN
  return !isNaN(parsed) && parsed > 0 ? parsed : fallback
}
 
// 使用:const llmTimeout = resolveTimeout(c.env, 'LLM', TIMEOUT.LLM)

总结

回顾一下这篇的要点:

  • 超时控制的核心是 AbortControllerfetch 原生支持通过 signal 取消请求
  • 封装 fetchWithTimeout 工具函数,把 AbortController + setTimeout 的细节藏起来
  • Hono 中间件给所有请求设置全局超时,通过 c.set() 注入 abortSignal,让下游都能拿到
  • 按路由前缀配置不同的超时时间,LLM 接口和普通 CRUD 接口的超时差距很大
  • 流式接口用「空闲超时」代替「总超时」——数据持续到达就不算超时,一段时间没新数据才断开
  • 超时后要清理资源:子请求通过 signal 自动取消,流通过 reader.cancel() 关闭,KV 写入放在流完整结束后
  • 超时配置集中到 src/config/timeout.ts,支持按环境区分和环境变量动态调整

下一篇会介绍 AI API 的错误处理,把超时、限流、模型报错等各种异常情况统一管理起来。