接口响应头明明写着 Content-Encoding: gzip,为什么调用 gzip.decompress(response.content) 却报 Not a gzipped file?使用 Requests 时,先检查是否解压了两次:response.content 通常已经经过内容解码,响应头仍可能保留服务端发来的 gzip 声明。
本文用只监听本机回环地址的 HTTP 服务,把“服务端发来的 gzip 内容”和“客户端交付给业务的字节”放在一起比对。适合排查采集响应乱码、解压失败,以及抓包字节与客户端结果不同的问题。
一、把解压、字符解码和解析分开
对于本文的 gzip JSON 接口,处理顺序是:gzip 编码的正文 → 解压得到 JSON 字节 → 按 UTF-8 解码为文本 → 解析为对象。
Content-Encoding 描述内容编码;Content-Type 描述媒体类型,可能附带字符集参数。HTTP 的传输分帧又是另一层。本文拿到的 raw 是 HTTP 库提供的正文流,不是含 TLS、响应头和 chunked 分帧的完整网络报文。HTTP 语义依据见 RFC 9110。
在 Requests 中,content 和 iter_content() 会处理 gzip 内容解码。想保留本例 gzip 正文字节,需要从一个尚未消费的新响应中读取 raw,并明确 decode_content=False。不要先读 content,再期待 raw 还能从头读取。API 行为可对照 Requests 官方说明。
二、一个能够看见两层字节的实验
实际运行日期为 2026-09-11,环境是 Python 3.13.13、Requests 2.33.1。下方是完整运行文件,保存为 gzip_layers_demo.py 后执行 python3 gzip_layers_demo.py。需要已安装 Requests;服务端部分仅使用 Python 标准库。
实验有三个端点:正确 gzip、普通 JSON、故意把普通 JSON 标成 gzip。另对固定压缩字节做一次截断校验。所有请求仅访问本机,不涉及真实业务接口。
"""Local HTTP fixture: distinguish coded body, decoded bytes, text and JSON."""
import gzip
import json
import platform
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests
PAYLOAD = {"id": "sku-001", "name": "测试商品", "price": "19.90"}
PLAIN = json.dumps(PAYLOAD, ensure_ascii=False).encode("utf-8")
PACKED = gzip.compress(PLAIN, mtime=0)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/gzip":
body, coding = PACKED, "gzip"
elif self.path == "/plain":
body, coding = PLAIN, None
elif self.path == "/wrong-header":
body, coding = PLAIN, "gzip"
else:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
if coding:
self.send_header("Content-Encoding", coding)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def must_fail(label, exc_type, operation):
try:
operation()
except exc_type:
print("PASS", label)
else:
raise AssertionError(label)
def main():
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
base = "http://127.0.0.1:%d" % server.server_port
print("Python", platform.python_version(), "Requests", requests.__version__)
print("coded_bytes", len(PACKED), "decoded_bytes", len(PLAIN))
try:
with requests.Session() as session:
session.trust_env = False # Keep this local fixture independent of proxies.
with session.get(base + "/gzip", timeout=3) as response:
response.raise_for_status()
assert response.headers["Content-Encoding"] == "gzip"
assert response.content == PLAIN
assert int(response.headers["Content-Length"]) == len(PACKED)
assert response.json() == PAYLOAD
print("PASS content is decoded although header still says gzip")
must_fail("double decompression rejected", gzip.BadGzipFile,
lambda: gzip.decompress(response.content))
with session.get(base + "/gzip", stream=True, timeout=3) as response:
response.raise_for_status()
coded = response.raw.read(decode_content=False)
assert coded == PACKED
assert json.loads(gzip.decompress(coded).decode("utf-8")) == PAYLOAD
print("PASS raw coded body can be decompressed once")
with session.get(base + "/gzip", stream=True, timeout=3) as response:
response.raise_for_status()
decoded = b"".join(response.iter_content(chunk_size=7))
assert decoded == PLAIN
print("PASS iter_content returns decoded chunks")
with session.get(base + "/plain", timeout=3) as response:
response.raise_for_status()
assert "Content-Encoding" not in response.headers
assert response.content == PLAIN
assert response.json() == PAYLOAD
print("PASS plain JSON needs no gzip step")
def wrong_header():
with session.get(base + "/wrong-header", timeout=3) as response:
response.raise_for_status()
_ = response.content
must_fail("incorrect gzip header rejected",
requests.exceptions.ContentDecodingError, wrong_header)
must_fail("truncated gzip rejected by explicit decompressor", EOFError,
lambda: gzip.decompress(PACKED[:-8]))
finally:
server.shutdown()
server.server_close()
worker.join()
if __name__ == "__main__":
main()
本轮实际输出:
Python 3.13.13 Requests 2.33.1
coded_bytes 72 decoded_bytes 59
PASS content is decoded although header still says gzip
PASS double decompression rejected
PASS raw coded body can be decompressed once
PASS iter_content returns decoded chunks
PASS plain JSON needs no gzip step
PASS incorrect gzip header rejected
PASS truncated gzip rejected by explicit decompressor
这组固定样本压缩后是 72 字节,解压后是 59 字节。很短的正文会因 gzip 元数据而变大,这不是压缩功能失效。此处响应头中的 Content-Length 是服务端写出的 72,不应拿它直接要求 len(response.content) 也等于 72。
第二项验证故意重复解压,得到预期异常才算通过。它没有“修复”任何目标站点。最后一项使用 Python 的显式解压器检查截断,不代表已测试所有 HTTP 库对截断 gzip 的处理。
三、按症状定位,不要统一套一层 decompress
响应头是 gzip,content 却以左花括号开头。 先确认客户端是否已经解码。本文直接把 content 与已知 JSON 字节比较,因此能确认该层;真实系统可以记录长度、哈希和有限的非敏感前缀。
raw 以 1f 8b 开头,直接 UTF-8 解码失败。 这两个字节是 gzip 的常见识别线索。按接口与库的约定完成解压,再解码文本。只检查前缀不足以验证整个 gzip 内容有效。
正文已经可读,但中文乱码。 排查字符集及错误的重复 encode/decode。response.encoding 影响文本转换,不能修复坏掉的 gzip 数据。对约定 UTF-8 的 JSON 样本,可显式解码验证。
ContentDecodingError。 本实验用错误的响应头复现了一种原因。实际还可能有内容损坏或中间层改写;保留状态、响应头与脱敏诊断样本再判断,不能见到该异常就断言是反爬。
JSONDecodeError。 先检查状态码、媒体类型和正文。HTML 提示页、空正文及 JSON 语法错误都可能到达这里;它与 gzip 错误属于不同阶段。
四、采集链路应约定谁负责哪一步
若共享下载模块交付的是已解码字节,就在它的接口说明中写清楚;解析器只负责字符解码和字段提取。保存调试样本时,文件名和元数据也标明 coded_body 或 decoded_body,避免下次重放拿错层。
对于大响应,应限制下载量、解压后的累计字节数和处理时间。本文的 gzip.decompress() 一次把固定小样本解到内存,不是生产级限额解压实现;不能直接用于任意大或不可信的压缩包。
本文只覆盖单层 gzip,不扩展声称 Brotli、zstd、复合 Content-Encoding 或流式中途失败已通过测试。Requests 的其他编码能力还取决于安装依赖,具体按运行环境核查。
参考:Requests 二进制响应、Python gzip、RFC 9110 内容编码。
本文使用 AI 辅助起草、整理与校核;所附本地实验已于上述日期实际运行。结果仅对应列出的环境和固定样本。

243

被折叠的 条评论
为什么被折叠?



