Gemini 流式请求的错误不走 HTTP 状态码:只判状态码会漏掉它

2026-08-25

数据截至 2026-08,价格与限额以各官网为准。本文只讲计费与接入机制,不列具体价格数字。

Gemini 官方错误参考文档里,错误对象的结构在流式和非流式下是一样的,但送达你手里的方式完全不同。文档写得很直白:标准(非流式)请求出错时,服务端设置 HTTP 状态码,并在 JSON 响应体里返回 error 对象;而流式请求(stream: true)出错时,错误是通过 SSE 流发送的一个 event_type: "error" 事件传过来的,error 字段的结构与非流式相同。这就意味着,一个只在拿到响应头之后判一次 HTTP 状态码、之后就闷头拼接增量分片的客户端,对流开始之后发生的错误是完全瞎的——状态码那一关它早就过了,错误藏在后面某个事件里,如果你的解析逻辑只认内容分片、把不认识的事件默默丢掉,最后得到的就是一段”看起来正常只是短了点”的输出。这一类问题格外不好查,因为它既不抛异常也不进错误日志。

先把作用域和两条通道分清楚

在展开机制之前,先把边界钉死,否则后面每一句都可能被你套用到错的接口上。本文引用的这份官方错误参考,对应的是 Interactions API——上面那个 stream: true 的参数写法也来自这里。Interactions API 和 generateContent 是两个不同的接口,官方文档里还有一处能佐证这件事:Interactions API 仅支持隐式缓存,不支持显式缓存,要用显式缓存必须改用 generateContent API。所以下面讲到的错误码清单、error 对象结构、SSE 错误事件,作用域都是这份参考本身;换到别的接口上是不是同一套形态,本文不做推断,后面还会专门再收一次口。

这份错误参考把错误统一收敛到一个 error 对象上,这个对象有两个字段:

  • code:机器可读,形式是 snake_case 的字符串
  • message:人类可读的描述

注意这里的 code 不是 HTTP 状态码那样的数字,而是像 invalid_requestrate_limit_exceeded 这样的字符串标识。官方给出的标准请求级错误码是与 HTTP 状态码对应的(比如 authentication 对应 401、not_found 对应 404),但文档给出的就只是这张对应表本身,并没有说明流式场景下状态码会呈现成什么样。这个空白很关键,下面还会再提一次。

传递方式的分叉就发生在这里:

  • 标准请求:服务端把 HTTP 状态码设置成对应值,同时在响应体里放 error 对象。你判状态码也行,读 error.code 也行,两条信息是一致的。
  • 流式请求:官方文档明确说明,错误通过 SSE 流发送一个 event_type"error" 的事件,其中 error 字段的结构与非流式相同。

也就是说,流式场景下 error 对象还在,codemessage 都还在,但它是作为流里的一个事件出现的。至于这种情况下 HTTP 层面呈现成什么样,官方文档在这一节里没有展开说明,所以本文不做任何推断——你要做的不是去猜状态码会变成什么,而是把 SSE 事件的类型判断补上。

只判状态码的客户端到底漏掉了什么

把上面这条机制翻译成代码里的具体后果,大概是三种典型写法会中招:

第一种,只在建连时判一次。 很多手写的流式客户端逻辑是:发请求 → 检查响应状态是否成功 → 成功就进入循环读流 → 循环里逐行解析、把内容拼起来。这个结构里,状态检查在循环外面只跑了一次。流开始之后送来的 event_type: "error" 事件,落到循环里被当成一条普通的流数据处理,如果解析器只关心内容增量字段,这条事件就被静默丢弃了。

第二种,解析时用”取不到就跳过”的宽容策略。 SSE 的每一帧本来就可能有各种类型,很多实现为了健壮性,会写成”尝试取出内容字段,取不到就 continue”。这种宽容在正常情况下确实避免了崩溃,但它同时也把错误事件一起吞了。宽容和沉默是一线之隔。

第三种,把”流正常结束”等同于”生成正常完成”。 流读完了、连接干净地关掉了,程序就认为这次调用成功。但流干净地结束和生成成功完成是两件事——错误事件本身也是流里的一帧,发完之后流该结束还是会结束。

这三种写法的共同点是:它们都假设”错误一定伴随一个失败的状态码”。而官方文档说明的机制正好推翻了这个假设。修法也很直接:在流的解析循环里显式判断事件类型,遇到 event_type"error" 的事件就取出 error.code 走错误分支,而不是靠外层那一次状态码检查兜底。

用 code 做分诊,别去正则匹配 message

既然 error 对象在流式和非流式下结构相同,那分诊逻辑其实可以复用——只是入口不同。这里要强调的是:分诊必须基于 code 字段message 是给人看的,官方把它定义为人类可读描述,用正则去匹配 message 里的关键词是很脆的做法。

还有一个容易被忽略的细节:官方明确说明,文档中未列出的错误码,API 会以标准 HTTP 状态文本的 snake_case 形式返回。这句话的含义是,code 是一个开放集合,不是固定的封闭枚举。所以你写的分支判断必须留一个兜底分支,把没见过的 code 原样记进日志,而不是让程序在 switch 里掉出去、或者干脆把未知 code 当成成功。这一点在流式场景下更要紧,因为流式的错误分支本来就跑得少,写坏了很久都不会被发现。

你可能在错误分支里收到哪一族 code

官方把错误码分成了三族,每一族的处置逻辑完全不同。先说清楚一个边界:官方文档并没有说明哪些 code 会以 SSE 错误事件的形式出现、哪些只会在非流式场景出现,它只说明了错误对象结构相同、传递方式不同。所以下面这三族是这份参考文档列出的全部错误码分组,而不是”流式专属清单”,别把它理解成一份流式错误的对照表。

第一族是标准请求级错误码,与 HTTP 状态码对应。包括请求格式或参数无效的 invalid_request、含未知参数的 parameter_unknown、密钥缺失或无效的 authentication、密钥无权访问该资源的 permission_denied、资源不存在的 not_found、模型名不存在的 model_not_found、两个语义不同的 429(rate_limit_exceededquota_exceeded)、客户端提前取消请求的 cancelled、服务端意外错误的 api_error,以及服务暂时过载或关闭的 service_unavailable。官方对后两个给出的处置都是重试,service_unavailable 明确要求配合指数退避。

第二族是生成被阻止码,属于政策、安全或内容限制触发的拦截,包括 safetyrecitationlanguageprohibited_contentspiiblocklist,以及一组图像相关的 image_safetyimage_prohibited_contentimage_recitationimage_other,还有不明政策原因的 content_blocked。官方对这一整族给出的统一处置是修改输入后重试——注意是修改输入,原样重试是没有意义的。

第三族是生成结构错误码,指模型输出本身的结构有问题:malformed_function_call(函数调用无法解析)、malformed_tool_callunexpected_tool_call(调用了请求中未声明的工具)、no_imagetoo_many_tool_calls(工具调用数超出上限)、missing_thought_signature(响应缺少必需的 thought 签名)。做 function calling 或 Agent 编排时,这一族值得提前在代码里把分支留好。

以上码名以官方错误参考文档当前版本为准。

两个 429 在错误分支里必须分开处理

这是整套错误码体系里最值得单独拎出来的一格,因为它直接决定你的重试逻辑救不救得了。

rate_limit_exceeded 表示超出了每分钟或每秒的请求数或 token 限制,官方给的处置是等待后配合指数退避重试——这一类退避是有效的,等一等确实会恢复。

quota_exceeded 表示超出的是每日配额,官方给的处置是等待配额重置或申请增加配额。这一类你退避多少次都没用,因为配额是按天算的,指数退避那点等待时间跨不过一整天。而且官方明确说明,每日请求数配额是在太平洋时间午夜重置的,不是本地时间也不是 UTC——如果你按本地零点去估”什么时候能恢复”,估出来的时间点会是错的。

除了这两个,官方还说明存在一层基于支出的速率限制,它在 RPM/TPM 之外单独评估,按一个滚动时间窗口计算(窗口长度以官方限流文档为准),是否适用取决于结算记录与账号状态,触发时返回 429 RESOURCE_EXHAUSTED。官方给的处置有三条:等待后重试、降低高费用请求的发送速率(用更小的上下文窗口或更短的输出)、持续触发则申请提高限流。第二条对流式场景尤其有参考意义——它指向的不是”重试得更聪明”,而是”把单次请求做小”。

还有一条限流机制上的基本前提值得复述:官方说明限流是按项目应用的,不是按 API 密钥应用,超出任意一个维度即触发,不是综合评估。所以在错误分支里想靠换密钥自救是无效的。这部分展开可以看站内的 RPM 与 TPM 到底限的是什么429 的通用处理思路

cancelled 是另一回事:别把客户端断连当服务端故障

流式调用里还有一个容易误判的码:cancelled,对应 499,官方的解释是客户端在请求完成前取消了请求,并注明通常是客户端断连,无需处置

这一条对流式场景特别有意义,因为长时间挂着的流本来就更容易撞上超时、代理层回收、页面关闭这些客户端侧的中断。如果你在监控面板上把所有非成功码一律标红告警,cancelled 会制造出大量噪音,看上去像是服务端在抖,实际上是你自己这边把连接断了。合理的做法是把它单独归一类,和 api_errorservice_unavailable 这种真正的服务端故障分开统计。至于流式中断之后要不要续传、怎么续,那是另一个话题,站内有一篇专门讲 流式中断与续传 可以对照着看。

接着该做什么

如果你手上已经有一份跑着的流式客户端,按这个顺序改动最省事:

  1. 在流的解析循环里加一个显式的事件类型判断,把 event_type"error" 的分支单独拉出来,不要再依赖循环外那一次状态码检查
  2. 错误分支统一读 error.code,按前面三族分诊,留一个兜底分支处理未列出的 code
  3. 把”可退避”(rate_limit_exceededservice_unavailableapi_error)和”退避没用”(quota_exceeded、生成被阻止族、生成结构族)在代码里明确分开
  4. cancelled 从服务端故障告警里摘出去

最后把作用域再收一次,这一步比上面四条都重要。前面这套 event_type: "error" 的判断逻辑,出处是 Interactions API 的错误参考,能确定的也只有这一条通道的形态。官方文档说明 Gemini 的 OpenAI 兼容层支持 stream=True 流式,但兼容层下错误具体以什么形态呈现,官方文档里没有找到相关说明;generateContent 原生接口的流式错误以什么形态送达,官方文档里同样没有找到相关说明。这两处空白不要用”应该也是一样的”去填——落到写法上就是:不管你走的是哪一条,都先把实际收到的原始帧原样打印一轮,看清楚事件类型字段叫什么、错误信息挂在哪一层,再按看到的结构写分支。返回结构本身也会随版本演进,站内 接口返回结构变化怎么应对 那篇讲的防御性解析思路,放在这里同样适用:先按实际帧结构解析,再对不认识的字段做兜底,而不是先假定一份结构再让代码去撞。

这件事上真正的坑,说到底就一句:流干净地结束,不代表这次生成成功了。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。