后端超时配置与流式响应

超时配置与流式响应

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

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

超时配置

要跑多久是「这个 Func」的性质,不是「这次请求」的性质,所以超时声明在 Func 源码里:

// /backend/func/image.ts
export const config = { timeoutMs: 120000 }

export async function generate(input, ctx) {
  const r = await fetch('https://api.example.com/v1/images', { ... })
  return { url: (await r.json()).url }
}

声明之后调用端就不用再关心超时了:

const result = await invoke('image.generate', input)
谁说了算规则
Func 里的 config.timeoutMs优先级最高,声明了就以它为准,调用端传的值被忽略
调用端的 timeoutMs只在 Func 没声明时生效,照常有效(存量写法不受影响)
都没有默认 5 秒

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

限制
单次执行上限300 秒,config.timeoutMs 再大也会被截断
CPU 时间约 10 秒。这条算的是你的代码在计算的时间,等 fetch、等数据库、等 SSE 事件都不计入,所以生图等 2 分钟没问题,但死循环 10 秒就会被打断
模块顶层2 秒。顶层只该定义函数和常量,重活放进方法里
Func 返回体积约 1 MiB,超出即失败,这是"把整表读出来返回"最先撞到的墙
fetch 响应体约 10 MiB,流式读也按累计字节算同一个上限
Func 代码体积约 256 KiB

诊断 context deadline exceeded

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

  1. 先看这个 Func 有没有 export const config = { timeoutMs }。绝大多数情况问题在这里:没声明,所以还是默认的 5 秒。
  2. 用相同输入、相同模型提高声明值重试。creght func run --timeout_ms / run_func.timeout_ms 只影响这次自测,跑通不能证明生产路径也会通,跑不通也不能证明平台有同样的硬限制。
  3. 如果提高后成功,就把声明值改到合适的大小,并在真实页面上验证一次完整路径。

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

如果报的不是超时而是 CPU(文案里会写明「加 timeout_ms 没有用」),那就是另一回事:你的代码在纯计算上花了太久。把计算挪到外部服务,或者改算法。调大超时对这种报错无效。

原生 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