请求取消与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() // 触发取消

三个部分:

  1. Controller — 持有 abort() 方法,发出取消指令
  2. Signal — 可传递的信号对象,可以传到任何需要感知取消的地方
  3. 事件监听 — 通过 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 })
      }
    }
  })
})

流式场景有两个需要注意的地方:

  1. fetch 的 signal:客户端断开时自动取消上游的流式响应
  2. 循环内检查 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 时,所有传入 signalfetch 都会立刻 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 暴露客户端取消信号,直接传给 fetchsignal 参数
  • AbortSignal.timeout() 提供超时取消,AbortSignal.any() 合并多个信号
  • 流式响应需要在循环内主动检查 signal.aborted,避免客户端断开后继续生成
  • 多个并发请求共享同一个 signal,任何一个取消就全部停下
  • 可以封装成中间件,让路由层只关注业务逻辑

请求取消本质上是一个信号传递问题。AbortController 提供了标准化的机制,让取消信号从请求入口一路传递到最底层的外部调用,不需要手动管理连接或定时器。