超时控制
要点
- AI API 的响应时间波动大,短则一两秒,长则几十秒,不做超时控制就等于把主动权交给了上游
- 超时控制的核心机制是
AbortController+setTimeout,fetch原生支持通过signal取消请求 - 在 Hono 里可以写一个全局中间件,给每个请求设置统一的超时上限
- 流式响应的超时处理方式不同——需要设置「空闲超时」,而不是从头到尾一个固定时间
- 超时之后不只是返回错误,还要清理已分配的资源:abort 子请求、关闭流、避免写入不完整数据
- 超时时间不应该硬编码在代码里,集中放在配置模块,按接口类型和环境区分
1. 为什么需要超时控制
先看一个真实场景。你用 fetch 调 OpenAI 的接口,正常情况 2-5 秒返回结果。但某天上游服务出了问题,某个请求 30 秒都没有响应。
如果没有超时控制,会发生什么:
- 你的 Worker 一直在等,占着一个执行上下文
- Cloudflare Workers 有 CPU time 上限,等到触发平台限制就会被强制终止
- 用户那边一直转圈,体验很差
- 用户可能已经重试了好几次,产生了多笔重复调用,而上游还在慢吞吞地处理
AI API 的响应时间比普通 REST API 波动大得多。同一个模型,短回答可能 1 秒,长回答可能 20 秒,遇到上游过载,30 秒以上也不罕见。你没法用一个固定的「正常耗时」来判断请求是不是卡住了。
所以需要一个机制:给每个请求设一个时间上限,超时就主动取消,返回错误。
2. 超时控制的核心:AbortController
AbortController 是取消异步操作的统一接口。基本用法分三步:
- 创建一个
AbortController实例 - 把它的
signal传给需要取消的操作(比如fetch) - 在合适的时候调用
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',
},
})
})有一个容易忽略的点:pull 里 reader.read() 是一个 Promise。如果在等待过程中触发了空闲超时,reader.cancel() 会让 reader.read() reject,进入 catch 分支。超时检测和流读取异常处理自然汇合到同一个地方。
7. 超时后的资源清理
超时取消不只是返回一个错误就完了。如果不清理已分配的资源,可能会造成内存泄漏或连接池耗尽。
子请求
传给下游 fetch 的 signal 会在 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)
}
}流式响应
streamWithIdleTimeout 的 cancel 回调已经通过 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)总结
回顾一下这篇的要点:
- 超时控制的核心是
AbortController,fetch原生支持通过signal取消请求 - 封装
fetchWithTimeout工具函数,把AbortController+setTimeout的细节藏起来 - Hono 中间件给所有请求设置全局超时,通过
c.set()注入abortSignal,让下游都能拿到 - 按路由前缀配置不同的超时时间,LLM 接口和普通 CRUD 接口的超时差距很大
- 流式接口用「空闲超时」代替「总超时」——数据持续到达就不算超时,一段时间没新数据才断开
- 超时后要清理资源:子请求通过 signal 自动取消,流通过
reader.cancel()关闭,KV 写入放在流完整结束后 - 超时配置集中到
src/config/timeout.ts,支持按环境区分和环境变量动态调整
下一篇会介绍 AI API 的错误处理,把超时、限流、模型报错等各种异常情况统一管理起来。