后端超时配置与流式响应

超时配置与流式响应

invoke 默认 5 秒、执行上限 300 秒、返回体积约 1 MiB——长任务撞的到底是哪一条,以及 context deadline exceeded 的诊断顺序。含 ctx.sse.send 加原生 Fetch 的完整 SSE 解析写法,和「流式不是后台任务」这条边界。

普通 invoke() 等的是一个完整 JSON 结果,默认 5 秒就超时。模型生成、慢的第三方接口和需要边算边显示的场景都会撞上这条线。这篇讲两件事:把超时设在对的地方,以及用原生 Fetch + SSE 做有界的增量输出。

超时配置

invoke 默认超时是 5 秒,Func runner 的默认最大执行时间是 300 秒。有界的模型生成或第三方请求可以在调用端设置更长时间;这不意味着可以启动脱离请求继续执行的后台任务。

const result = await invoke('image.generate', input, {
  timeoutMs: 120000,
})

同时生效的还有几条硬限制,它们不会因为放宽超时而变化:

限制
invoke 默认超时5 秒,由调用端 timeoutMs 覆盖
单次执行上限300 秒
Func 返回体积约 1 MiB,超出即失败——这是"把整表读出来返回"最先撞到的墙
Func 代码体积约 256 KiB

诊断 context deadline exceeded

遇到 context deadline exceededcontext timeout 时,按顺序做三件事:

  1. 先检查页面实际传给 invoke(..., { timeoutMs }) 的值。绝大多数情况问题在这里——调用端还是默认的 5 秒。
  2. 用相同输入、相同模型提高超时重试。creght func run --timeout_ms / run_func.timeout_ms 只影响这次自测,跑通不能证明生产路径也会通,跑不通也不能证明平台有同样的硬限制。
  3. 如果提高后成功,就提高生产调用端的 timeoutMs,并在真实页面上验证一次完整路径。

不要把缩短输出、降低 max_tokens、切换模型或供应商、创建新表、伪造后台任务当成超时修复。这些都会改变功能本身,而问题只是一个数字设小了。

原生 Fetch + SSE 流式响应

需要有界增量输出时,服务端通过 ctx.sse.send 发事件,浏览器直接使用原生 fetchReadableStream不需要 invokeStream 这样的封装

// /backend/func/writer.ts
export async function main(input, ctx) {
  ctx.sse.send('token', { text: 'Hello' })
  ctx.sse.send('token', { text: ' world' })
  return { ok: true }
}

浏览器侧要自己解析 SSE 帧。关键是 reader.read() 返回的是任意字节块,不保证一个块正好是一个事件:

const response = await fetch('/func/writer?stream=1&timeout_ms=120000', {
  method: 'POST',
  headers: {
    Accept: 'text/event-stream',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(input),
})
if (!response.ok || !response.body) throw new Error('Func stream failed')

const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })

  const frames = buffer.split(/\r?\n\r?\n/)
  buffer = frames.pop() || ''
  for (const frame of frames) {
    const event = frame.match(/^event:\s*(.+)$/m)?.[1]
    const data = frame.match(/^data:\s*(.+)$/m)?.[1]
    if (event === 'token' && data) {
      output += JSON.parse(data).text
    }
  }
}
  • 必须跨读取缓存,并且只按空行分隔 SSE frame,最后一段不完整的留在缓冲区里等下一块。
  • 流式路径用 URL 上的 ?stream=1&timeout_ms=...,不是 invoke() 的选项。
  • 平台负责发最终的 doneerror 事件,两个都要处理。
  • 第一个事件发出后不能再修改 Cookie:响应头此时已经提交。需要设置会话或 Cookie 的动作要放在第一次 send 之前。
  • 调用超时同样生效,流式不等于无限流。

流式不是后台任务

SSE 让用户更早看到进度,但它仍然是一次请求内的输出。Func 不支持脱离请求继续执行:没有 setTimeout/setInterval,没有任务队列,也没有"先返回再慢慢算"。

如果一件事确实超过 300 秒,就把它拆成用户可见的多步:每一步是一次有界调用,中间状态落在 JSON 表里,由页面驱动下一步——而不是让一次调用挂在那里。

验收清单

  • 长耗时调用在生产页面上设置了与实际耗时匹配的 timeoutMs,并验证过真实路径。
  • 返回值没有夹带大段内容,没有接近 1 MiB 的返回体。
  • SSE 解析跨读取缓存,按空行切帧,处理了 doneerror
  • 需要设置 Cookie 的逻辑在第一个 SSE 事件之前完成。
  • 没有用计时器、轮询或伪造的后台任务来绕过超时。

Render diagnostics