请求取消与AbortController
要点
- AbortController 是内置 API,用来把「取消」动作从一个地方传递到另一个地方
- 客户端断开连接后,如果后端还在继续处理,就是在浪费算力和费用
- Hono 的
c.req.raw.signal提供了客户端的取消信号,可以直接传递给fetch AbortSignal.timeout()实现超时取消,AbortSignal.any()合并多个信号- 流式响应需要主动检查
signal.aborted,否则客户端断开后还会继续生成 - 多个并发请求共享同一个 signal,任何一个取消就全部撤回
1. 从一个具体的问题开始
假设你写了一个调用 AI API 的接口,生成过程需要 5 到 10 秒:
// src/routes/generate.ts
app.post('/api/generate', async (c) => {
const { prompt } = await c.req.json()
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
}),
})
const data = await response.json()
return c.json(data)
})这个接口能正常工作。但有一个容易被忽略的情况:用户在等待的 5 秒里关掉了浏览器标签页。
此时客户端已经断开,但 Worker 里的 fetch 还在等 OpenAI 返回结果,拿到结果之后还会尝试序列化、发送响应——全部白费。
问题不是「怎么处理取消」,而是「怎么知道客户端已经走了」。
2. AbortController 是什么
AbortController 是一个内置类,作用很简单:提供一个开关,通知绑定的操作「该停了」。
// src/utils/abort-basic.ts
const controller = new AbortController()
const signal = controller.signal
// signal 的关键属性:
// signal.aborted —— 是否已经取消
// signal.addEventListener('abort', fn) —— 取消时触发
signal.addEventListener('abort', () => {
console.log('收到取消信号')
})
controller.abort() // 触发取消三个部分:
- Controller — 持有
abort()方法,发出取消指令 - Signal — 可传递的信号对象,可以传到任何需要感知取消的地方
- 事件监听 — 通过
abort事件或signal.aborted判断是否已取消
abort() 是一次性的,只能从 pending 变成 aborted,不能反过来。
3. 在 fetch 中使用 signal
fetch 的第二个参数接受 signal。把 signal 传进去,调用 abort() 时正在进行的请求会自动终止:
// src/utils/fetch-with-abort.ts
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000) // 3 秒后取消
try {
const response = await fetch('https://api.example.com/data', {
signal: controller.signal,
})
const data = await response.json()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('请求被取消了')
} else {
throw error
}
}请求被取消后,fetch 抛出 AbortError。通过 error.name === 'AbortError' 可以区分正常网络错误和主动取消。你不需要手动关闭 socket——signal 传进去之后,取消的责任就交给了 fetch 实现。
4. 在 Hono 中传递客户端取消信号
Hono 的请求上下文已经内置了取消信号。通过 c.req.raw.signal 拿到当前请求的 AbortSignal,直接传给下游的 fetch:
// src/routes/generate-cancellable.ts
app.post('/api/generate', async (c) => {
const { prompt } = await c.req.json()
const signal = c.req.raw.signal // 客户端的取消信号
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
}),
signal, // 客户端断开时,这个 fetch 也会自动取消
})
const data = await response.json()
return c.json(data)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return c.json({ error: 'Client disconnected' }, 499)
}
throw error
}
})signal 从 Hono 的上下文流到 fetch,取消动作自动传导。客户端断开 → signal 触发 abort → fetch 自动取消 → 抛出 AbortError → 进入 catch。
5. 超时取消与信号合并
给请求设一个最大等待时间,超时后自动停掉。用 AbortSignal.timeout() 实现:
// src/utils/timeout-signal.ts
const timeoutSignal = AbortSignal.timeout(30000) // 30 秒超时
try {
const response = await fetch(url, { signal: timeoutSignal })
const data = await response.json()
} catch (error) {
if (error instanceof DOMException && error.name === 'TimeoutError') {
return c.json({ error: 'Request timed out' }, 504)
}
}超时信号抛出的错误类型是 TimeoutError,和客户端取消的 AbortError 不同,方便区分。
同时支持客户端取消和超时
用 AbortSignal.any() 把多个 signal 合并成一个,任何一个触发就整体取消:
// src/utils/combined-signal.ts
app.post('/api/generate', async (c) => {
const { prompt } = await c.req.json()
const clientSignal = c.req.raw.signal
const timeoutSignal = AbortSignal.timeout(30000)
const signal = AbortSignal.any([clientSignal, timeoutSignal])
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
}),
signal,
})
const data = await response.json()
return c.json(data)
} catch (error) {
if (signal.aborted) {
if (timeoutSignal.aborted) {
return c.json({ error: 'Request timed out' }, 504)
}
return c.json({ error: 'Client disconnected' }, 499)
}
throw error
}
})通过检查各个子 signal 的 aborted 状态,可以判断是超时还是客户端断开。
6. 流式响应中的取消处理
流式输出是 AI API 的常见模式。服务端逐步返回 token,客户端实时显示。在 Hono 中用 streamSSE 实现:
// src/routes/stream.ts
import { streamSSE } from 'hono/streaming'
app.post('/api/stream', async (c) => {
const { prompt } = await c.req.json()
const signal = c.req.raw.signal
return streamSSE(c, async (stream) => {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
stream: true,
}),
signal, // 客户端断开时,上游流式响应也会取消
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
if (signal.aborted) {
reader.cancel()
break
}
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((l) => l.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6)
if (data === '[DONE]') return
await stream.writeSSE({ event: 'message', data })
}
}
})
})流式场景有两个需要注意的地方:
fetch的 signal:客户端断开时自动取消上游的流式响应- 循环内检查
signal.aborted:每次读取 chunk 之前主动检查,如果已取消就调用reader.cancel()终止读取
流式请求比单次请求更需要取消机制——一次非流式请求浪费的是等一个响应的时间,一次流式请求浪费的可能是持续几十秒的连接和计算资源。
7. 级联取消:多个并发请求
一个接口需要同时调用多个外部服务时,如果其中一个失败或客户端断开,其余的也应该取消:
// src/routes/cascade-cancel.ts
app.post('/api/compose', async (c) => {
const { prompt } = await c.req.json()
const clientSignal = c.req.raw.signal
const timeoutSignal = AbortSignal.timeout(15000)
const signal = AbortSignal.any([clientSignal, timeoutSignal])
try {
const [aiResult, searchResult, modResult] = await Promise.all([
fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: prompt }] }),
signal,
}).then((r) => r.json()),
fetch('https://api.search.example.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: prompt }),
signal,
}).then((r) => r.json()),
fetch('https://api.moderation.example.com/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prompt }),
signal,
}).then((r) => r.json()),
])
return c.json({ ai: aiResult, search: searchResult, moderation: modResult })
} catch (error) {
if (signal.aborted) {
if (timeoutSignal.aborted) {
return c.json({ error: 'Request timed out' }, 504)
}
return c.json({ error: 'Client disconnected' }, 499)
}
return c.json({ error: 'Upstream service failed' }, 502)
}
})Promise.all 的行为是:任何一个 Promise reject,整体就 reject。当 signal 触发 abort 时,所有传入 signal 的 fetch 都会立刻 reject 为 AbortError。
级联取消的关键在于所有并发请求共享同一个 signal。客户端断开 → 三个 fetch 全部自动取消 → Promise.all reject → 进入 catch。
8. 封装成可复用的中间件
上面的代码里,每个路由都要写一遍 signal 提取和超时设置。可以抽成中间件:
// src/middleware/request-cancel.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
export const requestCancelMiddleware = (config: { timeoutMs?: number } = {}) => {
return createMiddleware<AppEnv>(async (c, next) => {
const signals: AbortSignal[] = [c.req.raw.signal]
if (config.timeoutMs) {
signals.push(AbortSignal.timeout(config.timeoutMs))
}
const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
c.set('abortSignal' as never, signal as never)
try {
await next()
} catch (error) {
if (signal.aborted) {
if (config.timeoutMs && signals[1]?.aborted) {
return c.json({ error: 'Request timed out' }, 504)
}
return c.json({ error: 'Client disconnected' }, 499)
}
throw error
}
})
}路由层使用时只需要从 context 取出 signal,传给下游的 fetch:
// src/routes/generate-with-middleware.ts
app.post('/api/generate', requestCancelMiddleware({ timeoutMs: 30000 }), async (c) => {
const { prompt } = await c.req.json()
const signal = c.get('abortSignal' as never) as AbortSignal
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: prompt }] }),
signal,
})
return c.json(await response.json())
})路由层不再需要关心超时设置和信号合并的细节。
总结
AbortController由 controller(发指令)和 signal(接收指令)两部分组成- Hono 通过
c.req.raw.signal暴露客户端取消信号,直接传给fetch的signal参数 AbortSignal.timeout()提供超时取消,AbortSignal.any()合并多个信号- 流式响应需要在循环内主动检查
signal.aborted,避免客户端断开后继续生成 - 多个并发请求共享同一个 signal,任何一个取消就全部停下
- 可以封装成中间件,让路由层只关注业务逻辑
请求取消本质上是一个信号传递问题。AbortController 提供了标准化的机制,让取消信号从请求入口一路传递到最底层的外部调用,不需要手动管理连接或定时器。