requests请求体打印报错?一文搞懂body类型与安全格式化
做接口联调或写爬虫的人大概率都写过类似这么一行调试代码print(json.dumps(resp.request.body, ensure_asciiFalse, indent2))我自己就靠它在本地反复确认过回调签名和请求参数拼接细节。但这套“万能打印”在 GET 请求上一直好好的一旦换成 POST屏幕上立马堆满TypeError红字刷屏让人一度怀疑是不是 requests 版本或者 Python 环境出了问题。问题其实出在一个非常基础的小地方resp.request.body在不同请求方式、不同传参方式下底层保存的类型完全不一样而json.dumps对其中某些类型尤其是 bytes根本不愿意处理。这篇文章会把报错机制、body 的构造过程、以及一个能兼容 JSON、表单、multipart、无 body 等各类场景的稳定打印方案完整拆开讲一遍。如果你也在写接口调试工具、排查第三方 SDK 的请求细节或者被测试框架里的请求体断言折磨过这篇应该能帮你省下不少时间。1. GET 正常、POST 报错先看 resp.request.body 到底是个什么类型1.1 一段最小复现代码里的现象差异先用最少的代码把问题复现出来import requests import json resp_get requests.get(https://httpbin.org/get) print(type(resp_get.request.body)) # class NoneType print(resp_get.request.body) # None resp_post requests.post(https://httpbin.org/post, json{name: 张三}) print(type(resp_post.request.body)) # class bytes print(json.dumps(resp_post.request.body, ensure_asciiFalse, indent2)) # TypeError: Object of type bytes is not JSON serializable看到没有GET 不炸核心原因不是“GET 比 POST 高级”而是 GET 请求发出去的时候通常不带请求体resp.request.body的值是None。json.dumps(None)这个操作在 Python 里是合法的输出结果就是字符串null。整个过程根本不涉及类型校验问题自然不会报错。但 POST 请求一旦携带数据request.body就不再是None而是已经被 requests 内部编码好的bytes或者str。而json.dumps能序列化的类型就那么几种dict、list、str、int、float、bool、None。bytes 属于二进制类型JSON 协议里又没有对应的概念所以标准库直接甩了一个TypeError出来简单粗暴毫不含糊。1.2 requests 在 prepare 阶段到底把 body 变成了什么这里有一个容易忽略的关键点resp.request并不是你调用requests.post()时传进去的那个Request对象而是它在内部做完所有准备工作之后生成的PreparedRequest对象。也就是说你看到的是“已经准备好、马上要发出去”的真实内容而不是你脑子里想的那个原始参数。PreparedRequest在准备 body 时会按照传入参数的不同组合做一次“预编码”规则大致可以整理成一张表调用方式body 的实际类型body 的实际内容示例requests.post(url)不传数据NoneNonerequests.post(url, json{name: 张三})bytesb{name: \xe5\xbc\xa0\xe4\xb8\x89}requests.post(url, data{name: 张三})strname%E5%BC%A0%E4%B8%89requests.post(url, data{name:张三})str{name:张三}原样保留requests.post(url, files{file: open(x.txt,rb)})文件流/生成器multipart 边界分隔的二进制流所以当你用json传参时requests 实际上是先json.dumps把它变成字符串再.encode(utf-8)变成 bytes。等到resp.request.body到手已经是二进制字段了。这里还要多说一句很多人以为只有 POST 会有 bodyGET 永远不会。其实如果手动给 GET 传data或jsonrequests 一样会把 body 准备出来一样会遇到同样的报错。所以标题里的现象本质不是“GET”和“POST”两个方法之间的差异而是有没有携带请求体、请求体被编码成了什么类型之间的差异。POST 只是因为绝大多数时候都带数据才成了这个报错的高发区。搞懂了这一层后面所有解决方案都会变得顺理成章。2. 报错堆栈的三种打开方式与根因定位2.1 TypeError: Object of type bytes is not JSON serializable这是最直接的报错形式也是标题写法会在 POST 请求上触发的第一道雷。触发代码不用多复杂body b{name: \xe5\xbc\xa0\xe4\xb8\x89} json.dumps(body, ensure_asciiFalse, indent2)抛出的异常信息非常明确TypeError: Object of type bytes is not JSON serializable为什么 bytes 不能序列化因为 JSON 协议只定义了一种文本格式没有“二进制字符串”的概念。标准库json模块的设计哲学是遇到不支持的类型不替你猜测意图直接抛错。现实中如果非要把 bytes 塞进 JSON通常的做法无非三种转成字符串decode、转成 base64、转成十六进制。但哪个才是你想要的结果json模块不会替你决定所以它干脆不处理。2.2 decode 之后又撞上 JSONDecodeError 的连环坑很多人在网上搜到这个报错第一反应是“那我不直接传 bytes先 decode 成字符串不就行了”。于是代码变成了print(json.dumps(resp.request.body.decode(utf-8), ensure_asciiFalse, indent2))这个改法在 body 原本是 JSON 的 bytes 时确实能输出一段像模像样的结果只不过它会整体包成一层引号字符串效果类似{\name\: \张三\}虽然丑但至少不报错。可一旦遇到用data传参的场景body 实际是name%E5%BC%A0%E4%B8%89这种 urlencoded 字符串decode()之后再json.dumps还能勉强输出但有人会忍不住想“我要的是美化后的 JSON”于是又在前面补了一步parsed json.loads(resp.request.body.decode(utf-8)) print(json.dumps(parsed, ensure_asciiFalse, indent2))这个时候如果 body 不是合法 JSON就会迎来第二道雷json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)一旦接口返回的数据格式和你预想的不一样这种连环坑能把调试时间翻倍。所以我一直强调一个观点在不确定 body 内容格式之前不应该默认它一定是 JSON也不应该默认它一定是字符串。2.3 ensure_asciiFalse 在控制台输出时又踩编码坑还有一类报错和数据本身没关系纯粹是输出环节的问题。在 Windows 命令行里跑同样的代码有时候会看到UnicodeEncodeError: gbk codec cant encode character \u78b0 in position 0: illegal multibyte sequence这个问题的根源是ensure_asciiFalse让json.dumps把中文字符原样输出了但 Windows 控制台的默认编码通常是cp936GBK而不是 UTF-8一旦遇到 GBK 编码不了的特殊符号print就炸了。解决思路有几个按优先级排序把输出重定向到文件用python script.py out.txt然后以 UTF-8 打开out.txt查看在脚本最前面设置sys.stdout.reconfigure(encodingutf-8)Python 3.7在运行时设置环境变量PYTHONIOENCODINGutf-8如果只是在日志里看直接配置logging.FileHandler指向一个 UTF-8 编码的日志文件比改控制台省心得多。这一层虽然不直接改变业务逻辑但在实际使用中几乎天天遇到尤其是团队里有人用 Windows、有人用 macOS 时同一个调试脚本跑出来的结果可能完全不同。3. 一个能通吃 JSON、表单、multipart、None 的安全格式化函数3.1 为什么不能继续用 json.dumps 直接处理 body前面已经把根因拆清楚了核心其实就一句话resp.request.body的类型和内容是不确定的json.dumps只能处理其中一部分情况。如果继续沿用“拿 json.dumps 一把梭”的思路无论怎么修都是打补丁。正确的建模方式是先判断类型和内容形态再决定怎么展示。具体拆分下来就是body 为None直接提示无 bodybody 为bytes先做一次decode解码失败的字符用占位符替换body 为字符串尝试json.loads成功说明是 JSON可以安全地重新dumps美化失败说明是表单或其他文本原样输出即可body 是文件对象或生成器不能把它当普通文本读取否则会消费掉真正的请求数据这类场景只做提示不做完整预览。3.2 完整实现import json from typing import Any def decode_body(body: Any) - str: 把各种类型的请求体统一解码成文本。 if body is None: return if isinstance(body, bytes): return body.decode(utf-8, errorsreplace) if hasattr(body, read): # 文件对象 / 流式请求体不能直接读取 return streaming body, cannot preview if isinstance(body, str): return body # MultipartEncoderGenerator 等其他对象 return repr(body) def pretty_body(body: Any) - str: 安全格式化请求体能 JSON 美化就美化不能就原样输出。 text decode_body(body) if not text.strip(): return no body try: parsed json.loads(text) return json.dumps(parsed, ensure_asciiFalse, indent2) except (json.JSONDecodeError, TypeError, UnicodeDecodeError): return text这个函数的关键点有两个。第一decode时用errorsreplace而不是默认的errorsstrict。原因很简单调试场景第一优先级是“能看到东西”个别无法解码的字节用\ufffd占位总比一个地方乱码导致整个请求体打不出来要强。等确认有价值了再针对具体接口做编码优化。第二先json.loads再json.dumps而不是直接json.dumps(text)。因为直接dumps一个字符串输出的永远是带引号的一行文本根本没有“格式化打印”的意义。只有先解析成 Python 对象再重新序列化才能真正达到indent2的美化效果。3.3 使用效果对照print(pretty_body(None)) # no body print(pretty_body(b{name: \xe5\xbc\xa0\xe4\xb8\x89})) # { # name: 张三 # } print(pretty_body(name%E5%BC%A0%E4%B8%89)) # name%E5%BC%A0%E4%B8%89 print(pretty_body(not json at all)) # not json at all从输出可以直观看出来这个函数不再假设传入的一定是 JSON碰到表单、纯文本也能原样打出来。这在调试真实接口时特别管用因为很多内部 API 的请求体既不是规范的 JSON也不是标准的表单而是各种奇怪的自定义格式。3.4 multipart 场景的进阶处理multipart/form-data的请求体比较特殊。它的 body 是二进制里面包含大量--boundary分隔符和字段头直接用上面那个函数能打印出原始文本但会非常长读起来眼睛疼。如果只是想快速看 multipart 里都有哪些字段可以做一个更简单的处理先按\r\n分行只提取Content-Disposition行和它后面的字段值片段。不过这个方法对二进制上传文件不太友好会把文件二进制内容混进来。实际项目中我建议做两层如果只是排查字段名用提取行的方式如果是排查文件上传问题直接看服务端收到的字节数和 MD5而不是在控制台打印全量内容。4. 把完整请求信息打出来method、url、headers、body 一把梭4.1 一个更完整的请求详情打印函数resp.request.body只是拼图的一块。真实调试时光看 body 往往不够method、url、headers 都是排查问题的关键信息。我自己在本地维护了一个dump_request函数把请求的每个维度都拼在一起def dump_request(resp) - str: req resp.request lines [ f{req.method} {req.url}, Headers:, ] for key, value in req.headers.items(): lines.append(f {key}: {value}) lines.append(Body:) lines.append( pretty_body(req.body).replace(\n, \n )) return \n.join(lines)大概效果是POST https://httpbin.org/post Headers: User-Agent: python-requests/2.31.0 Accept-Encoding: gzip, deflate Accept: */* Connection: keep-alive Content-Type: application/json Content-Length: 27 Body: { name: 张三 }有了这个输出很多时候不用抓包就能定位问题URL 拼错了、Header 少了、Content-Type 对不上、body 格式不对一眼就能看出来。4.2 处理请求头里容易被忽略的细节req.headers是 requests 的CaseInsensitiveDict遍历时大小写不敏感打印出来比较整齐。但有几个细节值得注意请求头里的Cookie是一个拼好的字符串而resp.cookies才是响应返回的 CookieJar两者别弄混如果用了SessionSession 级别设置的 Header 会合并到每个请求里所以打印出的 Headers 可能比你自己传的多好几行有些自定义 Header 的值是列表类型比如某些网关的X-Forwarded-*直接拼进字符串会变成 Python 风格的列表稍微处理一下更可读。4.3 在 Session 上挂 hook实现全局请求留痕每次手动调dump_request(resp)还是有点麻烦。更稳妥的做法是把打印逻辑挂到Session的 hook 上让每个请求自动留痕import requests def logging_hook(resp, *args, **kwargs): print(dump_request(resp)) session requests.Session() session.hooks[response].append(logging_hook) # 之后所有通过 session 发出的请求都会自动打印 resp session.post(https://httpbin.org/post, json{name: 张三})hooks[response] 是在收到响应之后触发的此时resp.request还能拿到完整的请求内容非常适合做调试留痕。注意一点如果请求本身因为网络错误、超时等原因根本没有返回响应这个 hook 不会触发那就要另想办法在异常分支里记录请求。4.4 打印时顺手做的两个防御第一请求头里可能有Authorization: Bearer xxx这类敏感信息调试日志一打出去就全裸奔了。建议在dump_request里加一个可选的脱敏参数把已知的敏感 Header 值替换成***。第二body 如果特别大比如上传了几十 MB 的文件内容直接打印会把控制台刷爆。实践中可以在函数里加一个截断阈值比如超过 2000 字符就只显示前 2000 个并追加... (truncated)。5. 从 requests 到 httpx以及流式请求体的隐藏坑5.1 httpx 的请求体结构和命名不一样越来越多项目开始用 httpx 替代 requests如果只记住了resp.request.body这个属性名换到 httpx 时会直接踩空。import httpx resp httpx.post(https://httpbin.org/post, json{name: 张三}) print(type(resp.request.content)) # class bytes print(resp.request.content)httpx 的Request对象没有body属性而是把请求体拆到了几个地方resp.request.contentbytes 类型等价于 requests 里最常见的 bodyresp.request.stream字节流对象用于流式请求不能直接当字符串处理resp.request.extensions一个字典可能携带原始请求的扩展信息。所以如果你手头代码既有 requests 又有 httpx建议封装一个统一的适配层先判断对象类型再取对应属性避免两个库混用时代码散落一堆判断。5.2 流式请求体可能只能读一次这是个大坑尤其是用files或自定义生成器作为请求体时。requests 在构造 body 时如果拿到的是一个生成器或者文件对象它不会一次性读入内存而是把生成器直接挂到请求里。一旦你在调试时对这个 body 做了读取操作比如body.decode()生成器就被消费掉了真正发出去的请求可能变成空 body或者发到一半数据断裂。我之前排查过一个诡异问题接口用requests.post(url, datagenerate_large_payload())本地调试时加了一行打印 body 的代码结果服务端收到的内容总是不完整而且每次缺的内容还不一样。后来才发现就是打印操作提前消费了生成器。遇到这种场景不要在发送前直接读 body。有几种变通方案用requests_toolbelt.MultipartEncoder预先构造一个可重复读取的对象先把原始数据保存到临时文件或内存再通过datafile_obj发送或者发送完请求后再从业务层重新构造一份日志数据而不是依赖resp.request.body。5.3 大 body 的截断输出无论 requests 还是 httpx打印大请求体都应该加一个截断逻辑这也是调试工具从“能用”到“好用”的分水岭def pretty_body_with_limit(body, limit: int 2000) - str: text pretty_body(body) if len(text) limit: return text[:limit] \n... (truncated) return text接口排错的时候你真正关心的是 key 名、参数类型和值的大致形态很少需要把完整内容全部读完。截断不仅能保护终端输出还能避免日志文件被无意义的二进制数据撑爆。6. 调试时不该忽略的几个安全与效率细节6.1 别把敏感字段打进日志这是我觉得最该强调的一点。请求体里经常躺着password、token、secret、id_card之类的敏感字段。调试工具如果原样打印并写入日志文件时间一长就成了一个巨大的敏感信息库一旦日志泄露后果很严重。最简单的做法是在pretty_body之前先做一层脱敏替换def mask_sensitive(data, sensitive_keys(password, token, secret)): if isinstance(data, dict): return { key: (*** if key.lower() in sensitive_keys else mask_sensitive(value)) for key, value in data.items() } if isinstance(data, list): return [mask_sensitive(item) for item in data] return data脱敏之后再走 JSON 美化流程既能看清结构又不会让保密信息泄底。这个习惯越早养成越好别等项目上线了才想起补。6.2 json 和 data 的区别直接影响 body 格式很多团队联调接口时反复出现服务端返回415 Unsupported Media Type或参数解析为空的问题。抓包一看Content-Type是application/x-www-form-urlencoded但代码里写的是json{...}或者反过来。请求体的调试输出能直接帮你看穿这个问题json参数requests 会设置Content-Type: application/jsonbody 是 JSON 字符串data传 dictrequests 会设置Content-Type: application/x-www-form-urlencodedbody 是 urlencoded 字符串data传字符串Content-Type 默认不自动设置body 按你传的原样发送。当任务返回的报错长在服务端时先把请求体打出来看一遍能省掉很多“我来回猜参数”的时间。6.3 pytest 中断言请求体时的正确姿势写测试时也经常碰到这个标题里的问题。用responses或requests_mock拦截请求后断言发的 body 应该是import json assert json.loads(resp.request.body) {name: 张三}不要直接写成assert resp.request.body {name: 张三}前面说过json参数产生的 body 是 bytesbytes 和 str 在 Python 里永远不相等等。这种断言平时可能不炸一旦遇到中文内容因为编码后的字节序列和你手写的 UTF-8 字符串根本没法直接比较整个测试就会莫名失败。统一用json.loads把 body 解析成对象再断言才是稳妥做法。6.4 给调试脚本配置一个可靠的输出环境最后一个小建议把调试脚本里的输出通道固定下来不要依赖“恰好运行在什么终端里”这种不确定因素。我的习惯是写一个setup_debug_logging()函数把日志同时输出到控制台和 UTF-8 编码的日志文件控制台编码出问题时不至于连日志都看不了。import logging import sys def setup_debug_logging(levellogging.DEBUG, log_filedebug.log): root logging.getLogger() root.setLevel(level) fmt logging.Formatter([%(asctime)s] %(levelname)s %(message)s) console logging.StreamHandler(sys.stdout) console.setFormatter(fmt) root.addHandler(console) file_handler logging.FileHandler(log_file, encodingutf-8) file_handler.setFormatter(fmt) root.addHandler(file_handler)FileHandler指定encodingutf-8后日志文件里的中文不会再出现乱码也不会受控制台默认编码影响。配合前面几个打印函数整条调试链路就非常顺了。最后再分享一个我个人习惯的做法把pretty_body、dump_request、mask_sensitive这几个函数单独放到一个http_debug.py模块里requests 和 httpx 的项目都能直接复用。遇到那种第三方封装好的 SDK内部不让你拿到原始请求对象时我会在调用层用一个自定义 adapter 或者 monkey patch 把PreparedRequest复制一份出来再走同样的打印流程。这样即使 SDK 内部逻辑再黑盒你也能知道请求到底以什么形态发出去的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →