AI API 返回 200 却解码失败?gzip、重复解压与响应头错配排查
区分 HTTP 内容编码、自动解压与 JSON 解析,排查 gzip 重复解压、响应头错配和长度异常;通过本机 HTTPX 示例说明为什么 JSON 能解析仍不代表压缩响应完整。
API 返回 HTTP 200,但程序随后报 DecodingError、Not a gzipped file、UTF-8 错误或 JSON 解析失败,并不矛盾:状态码是在正文读取、解压和解析之前就可以收到的信息。先判断失败发生在哪一层,才能避免把重复解压当成模型故障,或把已经执行的生成请求再发一次。
本文处理非流式 JSON 响应的 HTTP 内容压缩问题,示例验证环境为 Python 3.12.10、HTTPX 0.28.1,资料核对日期为 2026-11-07。它不是服务商速度测评,也不处理 SSE 事件拼接。实验只使用本机 HTTP 服务和自行构造的数据,没有调用真实付费 API。
1. 三个响应头分别描述什么
| 响应头 | 主要含义 | 常见误判 |
|---|---|---|
| Content-Type: application/json | 解开内容编码后,表示的数据类型是 JSON | 看到 JSON 类型就直接把任意原始字节当 UTF-8 |
| Content-Encoding: gzip | 这份表示经过了 gzip 内容编码 | 看到头部还在,就认定 SDK 交付的正文还没解压 |
| Content-Length | 在普通带正文的响应中,描述所传输内容的字节长度 | 把它直接当成解压后的 JSON 大小或字符数 |
RFC 9110 区分了媒体类型、内容编码和长度。[1] 若实际传输的是 gzip 压缩内容,Content-Length 通常对应压缩后的内容字节数,不是解压后字符串的长度。HEAD、304 等没有实际传输相应正文的情况另有规则;HTTP/1.1 分块传输也不能按“必须有 Content-Length”判断。
Transfer-Encoding: chunked 是 HTTP/1.1 消息传输分帧问题,不等于 gzip。HTTP 客户端交给应用的“原始响应体”通常也已经去掉传输分帧,并不是抓包中的所有字节。中文字符数、UTF-8 字节数、gzip 字节数和网络总流量不能混用。
2. 先确认手里的字节是否已经解压
HTTPX 官方说明,常规响应内容会自动解码 gzip、deflate;流式 iter_bytes() 同样交付经过内容解码的字节。iter_raw() 则不应用 HTTP 内容解码。[2]
因此,对已经正常读取的 response.content 再调用 gzip.decompress,可能得到 Not a gzipped file。这通常说明传入的数据已是 JSON 等普通字节,不足以证明服务器发送了错误 gzip。响应头仍保留 Content-Encoding,也不能单独证明应用拿到的是压缩字节。
按顺序检查:
- 找到最先读取正文的地方:HTTP 库、SDK、日志中间件、代理转发代码,还是自己的 gzip 函数。不要只看最终报错行。
- 核对该读取接口交付的是内容解码后的字节、文本,还是保留内容编码的原始字节。每层只承担一次相应解码。
- 普通 JSON 消费路径使用库的正常解码结果;不要因为响应头写着 gzip 就再手动解压。
- 如需定位头部与正文错配,在受控、无敏感数据的小样本中使用未消费的流式响应及 iter_raw()。已读完的响应不能当成可以倒带的网络流;不要为了取得两份数据自动重发真实生成请求。
gzip.decompress 会将展开结果放入内存。[3] 即使压缩数据不大,展开后也可能很大;它适合这里已知规模的小样本,不能把未知生产响应整块交给它,再声称已经有内存保护。
3. 用失败阶段缩小排查范围
| 现象 | 优先核对 | 不要直接下的结论 |
|---|---|---|
| 自己调用 gzip.decompress 才报错,库已能解析 JSON | 是否对解码后的内容做了第二次解压 | 上游压缩一定损坏 |
| HTTPX 读取正文时出现 DecodingError | 声明的内容编码与实际字节是否一致、压缩数据是否损坏 | 换模型或 Key 就能解决 |
| 原始字节是压缩内容,但响应没有 Content-Encoding | 哪层删除了编码声明或发送了错误元数据 | Content-Type 为 JSON 就可以忽略编码 |
| JSON 类型正确,读取后是空内容 | 状态是否允许空正文,接口是否本就没有返回 JSON | 空响应一定是压缩故障 |
| 非压缩正文仍有 UnicodeDecodeError | 字符编码、原始字节和错误层级 | 用替换字符读取就算修复 |
| 连接结束早于声明长度 | HTTP 消息是否截断、网关是否保留了过时长度 | 只补一个 JSON 括号就可恢复 |
错误类型是定位线索,不是责任归属。一个中间网关可能改变正文或响应头,客户端也可能改变读取方式。对照时记录同一跳的状态、Content-Type、Content-Encoding、Content-Length、客户端版本、时间与脱敏请求编号。不要直接输出完整响应、Authorization、Cookie 或代理凭证。
若拿到的是 HTML、登录页或 SSE,先处理实际媒体类型;SSE 的事件边界与完成判断见流式响应排查。需要联系平台时,使用最小复现与脱敏工单模板。
4. 用一个小函数区分解码、文本与 JSON 错误
下面函数接收尚未读取的 HTTPX 流式 Response,不发送请求。调用方应在 client.stream 的上下文内部调用它,并让上下文负责在成功或异常时关闭响应。客户端仍需设置适当的连接、读取等超时,保持 TLS 验证;不要在函数失败后自动重新提交生成任务。
示例故意只接受未压缩、identity 或单层 gzip 的非流式 JSON。它不是通用解压器;遇到 br、zstd、deflate 或多层编码时会明确拒绝,不能据此认定这些编码违反 HTTP 规范。接受 application/*+json 也只说明媒体类型合适,不代表业务成功。
import json
import httpx
class ResponseCheckError(ValueError):
pass
def read_json_response(response, max_decoded=65536):
if type(max_decoded) is not int or max_decoded <= 0:
raise ValueError("max_decoded must be a positive integer")
if not 200 <= response.status_code < 300:
raise ResponseCheckError(f"HTTP_{response.status_code}")
media = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if not (media == "application/json" or
(media.startswith("application/") and media.endswith("+json"))):
raise ResponseCheckError("NOT_JSON_MEDIA_TYPE")
coding = response.headers.get("content-encoding", "").strip().lower()
if coding not in ("", "identity", "gzip"):
raise ResponseCheckError("UNSUPPORTED_CONTENT_ENCODING")
body = bytearray()
try:
for chunk in response.iter_bytes():
if len(body) + len(chunk) > max_decoded:
raise ResponseCheckError("DECODED_BODY_TOO_LARGE")
body.extend(chunk)
except httpx.DecodingError:
raise ResponseCheckError("CONTENT_DECODING_FAILED") from None
if not body:
raise ResponseCheckError("EMPTY_JSON_BODY")
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
raise ResponseCheckError("INVALID_UTF8") from None
try:
return json.loads(text)
except json.JSONDecodeError:
raise ResponseCheckError("INVALID_JSON") from None
max_decoded 的示例值是 65,536 字节,限制的是函数保存并交给文本解析的已解码正文;可根据接口约定调整,不是模型推荐上限。它没有使用压缩后的 Content-Length 代替这一判断,也不会靠忽略 UTF-8 错误“修好”内容。
必须明确两个边界。第一,底层解压器可能先产生较大的单块数据,JSON 解析还会分配额外对象,所以这不是进程内存硬上限,也不是完整的压缩炸弹防护。面对不可信的大响应,需要在应用架构中另设可执行的压缩与展开预算、并发限制和资源隔离。第二,函数沿用 HTTPX 的内容解码器,不额外保证 gzip 文件格式完整。
ReadTimeout、RemoteProtocolError 等网络异常会继续交给调用方;它们没有被误报为 INVALID_JSON。返回的对象仍要按接口契约检查业务错误、字段、完成状态和所需 Schema。Python 默认 json.loads 的语义也保留在这里:本例不额外拒绝重复键或非标准数值常量,不充当严格 JSON Schema 验证器。
5. JSON 能解析,也不一定代表压缩响应完整
本机验证构造了一份合法 JSON 的 gzip 内容,然后移除 gzip 末尾的八字节尾部,并把 HTTP Content-Length 改成实际发送的截短长度。在 HTTPX 0.28.1 中,这个特定样本仍交付了完整 JSON 字节,上述函数可以解析;对同一份已知很小的原始样本使用 Python gzip.decompress,则抛出 EOFError。
这不表示所有截断都能通过,也不表示“只要缺八字节就能恢复”。另一个样本保留原 Content-Length 却提前结束连接,会触发 HTTPX 的 RemoteProtocolError;修改 gzip 校验数据的样本触发 DecodingError。不同截断位置、编码组合和客户端版本可能有不同结果。
HTTPX 0.28.1 的 gzip 解码源码通过 zlib 解码及 flush,并不在这里额外检查 gzip 结束标记状态。[4] 因此应分别验收三件事:HTTP 消息有没有按其分帧结束、内容编码格式是否完整、解码后的业务响应是否满足完成条件。JSON 语法正确只能覆盖其中一部分。
本次共通过 25 项本地检查,覆盖正常明文与 gzip、中文 UTF-8、媒体类型、空响应、HTTP 错误、头体错配、校验损坏、两种截断、重复解压、原始与解码字节、上限边界、异常关闭和后续请求恢复。这里没有测试真实平台、TLS、HTTP/2、性能或计费,未把实验结果写成服务商故障结论。
6. 网关修改正文时,要同步处理元数据
如果转发层使用 HTTPX 读出已经解码的正文,却把上游 Content-Encoding: gzip 和旧 Content-Length 原样复制给下游,下游可能再次解压普通 JSON,或按过时长度等数据。这是一条需要通过实际链路证据确认的故障路径,不是看到错误就能断定的原因。
维护者应选定并验证一种一致做法:
- 保留内容编码的转发路径:正文保持相应编码,声明与实际内容一致;传输分帧由服务框架按当前连接处理,不能随意复制逐跳头。
- 已解码正文的转发路径:不再宣称正文仍是原 gzip 内容,重新计算或交给框架生成合适长度;若后续层重新压缩,则应由那一层生成相应编码和长度元数据。
只删除 Content-Encoding 却保留压缩长度,或只改 Content-Length 却保留错误编码,都不是完整修复。服务框架、反向代理和缓存可能各自执行压缩,检查实际部署的链路,不凭一段代码猜测最后到达客户端的内容。
在接口及网络策略允许时,可对同一个安全、无凭证诊断入口做 Accept-Encoding: identity 的单变量对照。它表达不使用内容编码的偏好,不能替代对实际响应头和正文的核对。[1] 不要因此全站关闭压缩,也不要把对照检查变成自动重试原来的付费 POST。
修复验收要回到原故障环境:确认正常响应、错误响应、空正文、压缩与不压缩两种路径,并核对真正的业务完成条件。解析失败不证明上游没有执行或扣费;需要重发时先按请求编号与账单记录核对,参考请求与账单对账教程。
7. 一手资料
资料于 2026-11-07 核对。HTTPX 实现结论限定于实验所用的 0.28.1,不推断其他版本一定相同。
- [1] RFC 9110:HTTP Semantics:第 8.4、8.6、12.5.3 节分别解释 Content-Encoding、Content-Length 与 Accept-Encoding。
- [2] HTTPX 官方 QuickStart:Binary Response Content 与 Streaming Responses,区分自动内容解码和 iter_raw。
- [3] Python 3.12:gzip:gzip.decompress 及无效压缩数据的异常类型。
- [4] HTTPX 0.28.1 gzip 解码实现:GZipDecoder 的 decode 与 flush。