☰
requests POST报错?非中文编码,是Content-Type错配
2026/10/20 16:45:11 网站建设 项目流程

先说结论:这个报错十有八九不是你中文编码的问题,而是你压根没把 Python requests 的 POST 请求体发送成服务端期望的 JSON 格式。只要看到Unrecognized token 'pageNo'这一句,整条排查路线其实已经被“剧透”了一大半。

最近我在对接一个查询类接口时正好踩了同一个坑——接口文档写得很明白,body 要 JSON,我图省事用data=传了个 dict,结果跑到带中文参数的场景就收到JSON parse error: Unrecognized token 'pageNo': was expecting ('true', 'false' or 'null')。一开始我也以为是的编码问题,毕竟报错里出现了中文,可后来把请求体抓出来一看,根子完全不在编码上,而是请求格式和 Content-Type 根本没配对。这篇文章就从头到尾还原一下整个排查过程,顺便把这类 JSON 解析报错的常见变体一起捋清楚。

1. 报错现场还原:从“有汉字才崩”到“必现”的复现实验

1.1 先写一个最小复现脚本

先说当时我写的代码,基本长这样:

import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } resp = requests.post(url, data=payload) print(resp.status_code) print(resp.text)

服务端返回的不是业务数据,而是一段异常响应:

JSON parse error: Unrecognized token 'pageNo': was expecting ('true', 'false' or 'null')

我把pageName从 payload 里去掉,只保留数字和 pageNo,再发一次,结果正常返回了。这就非常迷惑:难道汉字真有这么大威力?加上汉字就触发解析错误?

后来我做了更细的对照实验,发现事情没那么简单。无中文时能通,很可能是因为服务端的某个边缘网关或过滤器做了宽容处理,一旦 body 里出现非 ASCII 字符,就踏入了一段完全不兼容的解析路径。这个判断不一定适用于所有后台,但足以让我意识到:直接看“有汉字/无汉字”这个维度,容易把问题归因到编码上。

1.2 三组对照实验结果

我把同一个接口试了四种发送方式,整理成一张表:

发送方式实际请求体(body)Content-Type结果
data=dict(含中文)pageNo=1&pageSize=20&pageName=张三application/x-www-form-urlencoded解析失败,报Unrecognized token 'pageNo'
data=dict(无中文)pageNo=1&pageSize=20application/x-www-form-urlencoded同一路径下同样解析失败
json=dict{"pageNo": 1, "pageSize": 20, "pageName": "张三"}application/json正常
data=json.dumps(dict){"pageNo": 1, "pageSize": 20, "pageName": "张三"}未手动设置时为表单格式取决于服务端是否检查 Content-Type,部分服务端仍会解析 JSON

看到这里,核心疑点其实已经浮出水面了。data=dict会把参数拼成key=value&key2=value2这种表单格式,而服务端接口要的是 JSON 文本。它拿着 JSON 解析器去解析一段pageNo=1&pageSize=20...,第一眼看到裸单词pageNo,当然不认。

1.3 汉字为什么成了“背锅侠”

既然无中文也报错,标题里那句“有汉字时解析错误”到底是怎么回事?我的理解是:汉字不是原因,但它是把问题推到明面上的“催化剂”。

对一个格式正确、Content-Type 也正确的 JSON 请求体来说,中文字符本身没有任何问题,JSON 标准允许字符串内出现任意 Unicode 字符,只要服务端按 UTF-8 读取就行。问题在于,一旦 body 被拼成表单串,里面出现中文后,整个 body 会先做一次 URL 编码,变成一堆%E5%BC%A0%E4%B8%89这样的百分号序列。服务端的 JSON 解析器在解析到第一个百分号之前可能还在尝试识别 token,遇到%这种字符就直接抛出异常。于是“带中文”看起来成了触发条件,实际上根子是在“请求格式错了”。

这就像你去一家只接受“一菜一单”的食堂,递了张写满菜名的纸,对方本来就看不明白;纸上还涂了几笔圈圈点点,你以为是墨迹影响了他认字,其实是他连你那张纸的格式都不接受。

2. 追根溯源:JSON 解析器到底在哪个环节说“不认识 pageNo”

2.1 把报错翻译成人话

Unrecognized token 'pageNo'是很多 Java 服务端 JSON 解析框架(尤其是 Jackson)在解析失败时给出的典型提示。它想表达的意思是:在一段期望出现 JSON 合法 token 的位置,我看到了一个没加引号的裸标识符pageNo。

合法的 JSON 对象长这样:{"pageNo": 1},键名必须用双引号包起来,冒号后面跟着值,值可以是数字、字符串、布尔值、数组和对象。而服务端拿到的 body 是pageNo=1&pageSize=20,解析器第一个看到的字符是p,后面跟着一整段pageNo,这不是 JSON 语法中的任何合法 token,于是它把整个单词当作非法 token 报出来。

你注意看那句was expecting ('true', 'false' or 'null'),这其实是 Jackson 在报告“我期望的是某个合法值起始符,但收到的是别的”。有些版本还会报was expecting a value,本质都一样——解析器在错误的位置碰到了一个无法归类的词。

2.2 用本地 mock 服务把请求体打出来

排查这类问题最快的方法,不是在线上环境猜,而是起一个本地 mock 服务,把服务端视角的东西全部打印出来。我用 Python 标准库就能做一个最小实现:

from wsgiref.simple_server import make_server def app(environ, start_response): length = int(environ.get("CONTENT_LENGTH", 0) or 0) body = environ["wsgi.input"].read(length).decode("utf-8", errors="replace") print("Method:", environ.get("REQUEST_METHOD")) print("Content-Type:", environ.get("CONTENT_TYPE")) print("Body:", body) print("----") start_response("200 OK", [("Content-Type", "text/plain")]) return [b"ok"] if __name__ == "__main__": with make_server("127.0.0.1", 8080, app) as httpd: print("mock server on 8080") httpd.serve_forever()

然后用 requests 分别发几种请求,mock 服务端打出来的内容一目了然。你会发现用data=payload时,收到的 body 是pageNo=1&pageSize=20&pageName=%E5%BC%A0%E4%B8%89,而 Content-Type 是application/x-www-form-urlencoded。这就解释了服务端为什么一脸懵。

2.3 URL 编码和 JSON 编码是两种“语言”

这里必须把两个概念分清楚。URL 编码(application/x-www-form-urlencoded)的规则是:参数名和参数值用=连接,参数之间用&连接,非 ASCII 字符按 UTF-8 编码后转成百分号加十六进制。而 JSON 编码的规则是:键值对放在{}里,键名用双引号包裹,值按类型书写,字符串类型也必须用双引号包裹。

这两套规则天然不兼容。requests 的data参数默认走前一套,json参数默认走后一套。服务端接口写@RequestBody或者等价注解时,明确要求接收 JSON 文本;你发一个表单串过去,服务端只能用 JSON 解析器硬啃,啃到第一个=就报错。

2.4 汉字到底有没有影响编码

不是所有场景都像上面这么简单。另有一种情况是:请求体确实是 JSON 结构,比如{"pageName": "张三"},但服务端读取时用了错误字符集,或者中间的网关把字节流转码转坏了,导致张字的 UTF-8 字节被错误切分,原本的字符串引号被截断或转义,这时候解析器可能会报Unterminated string或Unexpected character。

怎么区分是格式问题还是编码问题?最直接的办法是看原始字节。用 mock 服务打印出来的 body 是这样的,就是格式问题:

Body: pageNo=1&pageSize=20&pageName=张三

如果打印出来长这样,那说明 JSON 结构完整,问题才可能出在编码链路:

{"pageNo":1,"pageSize":20,"pageName":"张三"}

注意:单纯“Unrecognized token 'pageNo'”这个报错,几乎不可能由中文编码引起,因为 pageNo 这个裸词在中文出现之前就已经非法了。

3. 修复实操:把请求体改对,顺便把编码理顺

3.1 首选方案:用 requests 的 json 参数

requests 提供了一个专门发送 JSON 的入口,直接写json=payload就行:

import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } resp = requests.post(url, json=payload) print(resp.status_code) print(resp.text)

这行代码背后做了两件事:一是把 payload 序列化成 JSON 字符串,二是自动把 Content-Type 设置为application/json。这两个条件同时满足,服务端就能用正常的 JSON 解析路径去处理。

还有一个很多人忽略的细节:requests 序列化 dict 时,默认ensure_ascii=True,也就是说“张三”会被写成"\u5f20\u4e09"。这其实是好事,因为\u转义序列是纯 ASCII 字符,能彻底规避网络传输过程中的字符集混乱问题,服务端不管用什么编码读取,转义序列都能被正确还原成中文。我见过不少同事为了日志好看,特意把ensure_ascii=False打开,结果碰到非 UTF-8 场景反而引入新坑。

3.2 需要保留中文可读时:手动构造 body 并显式声明编码

有些老系统或者特殊中间件确实需要服务端日志里直接看到中文,这时候你可以手动构造 JSON 字符串,但必须把 Content-Type 和 charset 一起显式带上:

import json import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } body = json.dumps(payload, ensure_ascii=False) resp = requests.post( url, data=body.encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, )

几个细节值得注意。

body.encode("utf-8")这一步把字符串转成字节数组,交给 requests 时不会再有隐式编码的二次猜测。如果你直接传data=body,requests 会自动按某种编码方式编码字符串,大多数时候是 utf-8,但一旦运行环境默认编码被改过,就可能出乱码。显式 encode 是最稳妥的。

另外,charset=utf-8写在 Content-Type 里,是明确告诉服务端按 UTF-8 解码。有些服务端框架不读这个 charset,默认按 UTF-8,那也没问题;遇到默认按 GBK 的老框架,这个声明就能救你一次。

3.3 如果服务端接口真的只认表单格式

反过来也有一种情况:接口文档写的是application/x-www-form-urlencoded,body 就是pageNo=1&pageSize=20&pageName=张三这种串。这时候你非要用json=,服务端可能返回 415 Unsupported Media Type,或者业务参数解析不出来。

判断标准很简单:看接口文档或 Swagger 里标注的 consumes 类型。如果写的是表单,就用data=payload;如果写的是 JSON,就用json=payload。这二者不能混。

用表格整理一下:

服务端接收类型推荐发送方式Content-Type 自动值
application/x-www-form-urlencodeddata=dictapplication/x-www-form-urlencoded
application/jsonjson=dictapplication/json
text/plain 或自定义格式data=str/data=bytes需手动设置

3.4 汉字编码相关的几个实操细节

围绕汉字,还有几个容易踩的边边角角,我一起说一下。

第一个坑:不要对 JSON 字符串再做一次 URL 编码。有人发现 body 里有中文后,会手动urllib.parse.quote一遍再塞进 JSON,结果中文变成%E5%BC%A0%E4%B8%89,放在字符串内部会被服务端原样当成字符串内容,业务数据直接错了。中文该以什么形态出现在 JSON 里,就保持什么形态,最多用ensure_ascii=False或True控制转义。

第二个坑:requests.Session 里遗留的自定义 Content-Type。如果你在 session 级别设置过headers={"Content-Type": "application/json"},后续某个接口想用data=dict发表单,requests 仍会沿用你手动设置的 headers,导致表单 body 配上了 JSON 头,服务端可能依然走 JSON 解析然后报错。所以每次请求要么显式传 headers,要么确认 session 上没挂全局头。

第三个坑:URL 里带中文参数。这是另一个问题,查询参数里的中文应该用params参数交给 requests 处理,同时设置好编码,不要自己去拼 URL。POST body 里的中文归 body 管,URL 里的中文归 params 管,两套逻辑不要混在一起。

4. 排查链路:从“带汉字就崩”到定位根因的四步法

4.1 第一步:打印你即将发出的请求头和请求体

requests 允许你先预览 PreparedRequest,不需要真的发出去就能看到 body 和 headers:

import requests req = requests.Request( "POST", "https://example.com/api/order/query", data={"pageNo": 1, "pageName": "张三"}, ) prepared = req.prepare() print("Headers:", prepared.headers) print("Body:", prepared.body)

输出里能看到 Content-Type 和 body 的真实内容。如果你发现 body 是pageNo=1&pageName=张三,那就可以确定发送的是表单格式,问题出在格式不匹配。

这里有个容易被忽略的点:prepared.body在 requests 内部可能是 bytes,打印时中文会直接显示,也可能显示转义序列,不影响判断。你重点看它是不是key=value&key2=value2这种形态。

4.2 第二步:起本地 mock 服务复现

上一节已经给了 wsgiref 的 mock 代码,这一步的关键是让服务端视角变得可见。我习惯把分支条件也加上:根据 Content-Type 走不同的处理路径,然后打印不同的提示。

def app(environ, start_response): length = int(environ.get("CONTENT_LENGTH", 0) or 0) body = environ["wsgi.input"].read(length).decode("utf-8", errors="replace") content_type = environ.get("CONTENT_TYPE", "") if "application/json" in content_type: print("收到 JSON 格式请求") elif "application/x-www-form-urlencoded" in content_type: print("收到表单格式请求") else: print("收到未知格式请求") print("Body repr:", repr(body)) print("Content-Type:", content_type) print("----") start_response("200 OK", [("Content-Type", "text/plain")]) return [b"ok"]

在本地跑起来之后,再用目标环境里失败的脚本打过去,观察打印结果。如果打印显示“表单格式请求”,而接口文档写的是 JSON,问题就非常明确了。

4.3 第三步:用二分法把变量拆开

当 mock 服务打印出来的信息还是不够清晰时,我习惯用二分法缩小范围。具体操作是这样:

  • 固定 URL 和请求头不变,只改 body:分别用data=和json=发一次,看响应差异。
  • 固定 body 不变,只改 Content-Type:手动设置headers={"Content-Type": "application/json"},对比手动设和不设时的响应差异。
  • 固定格式,只增删中文:在json=模式下分别带中文和不带中文,发现都能成功,说明格式修好后中文本身不是问题。
  • 如果带不带中文结果不同,再去检查请求前的中文字符串有没有被别的地方转码过。

这套流程下来,基本能把“格式问题”“Header 问题”“编码问题”三个嫌疑分离清楚。

4.4 第四步:让后端同事把原始 body 打出来

如果你本地 mock 已经复现,但线上环境还是有差异,那就需要服务端配合。不要只看异常堆栈,要让后端在入口处把request.getBody()或等价原始内容打出来,看看线上真正收到的字节是什么。很多时候中间经过网关、负载均衡、ESB,body 可能被二次编码或格式转换,这只有线上日志能暴露。

我遇到过一次,本地 mock 一切正常,线上同样代码就报错,最后发现是统一的 API 网关把 JSON body 里所有中文做了 UTF-8 百分号转码,导致{"pageName":"张三"}变成了{"pageName":"%E5%BC%A0%E4%B8%89"},服务端解析出来的就是百分号串。这类问题如果不看网关配置,纯靠客户端代码排查很难发现。

5. 同类 JSON 解析报错的变体与发送前自查清单

5.1 高频报错速查表

这类报错在对接第三方接口时非常常见,我把几种高频变体整理成了表,方便大家对照:

报错关键字含义常见原因
Unrecognized token 'pageNo'解析器看到一个裸标识符body 是表单格式但被当 JSON 解析;键名漏了双引号
Unexpected character ('x')当前上下文中出现不该出现的字符JSON 尾部多了逗号;字符串用了单引号;注释未移除
Unterminated string字符串没有结束引号中文被中间层截断;反斜杠转义处理不当
Illegal character (<CTRL-CHAR>)出现控制字符字符集不匹配;body 里混入了不可见字符
Expected ':' but got 'x'键名后面不是冒号键名与值之间少了冒号,或键名没闭合

每一条背后的排查方法都类似:先把实际 body 抓出来看结构,再对照 Content-Type 判断是否匹配,最后检查编码链路。

5.2 发送前自查清单

我在这个坑里爬出来后,给自己定了一个发送前自查流程,基本不踩雷了:

  • 先看接口文档要求的 Content-Type 是什么。
  • 如果是 JSON,优先用json=参数,不要手动拼字符串。
  • 如果必须手动拼 JSON,确认 headers 里 Content-Type 带上charset=utf-8。
  • 确认 body 里没有残留的单引号、冗余逗号、注释。
  • 确认没有对 JSON 字符串再做 URL 编码。
  • 如果用一个 requests.Session 复用连接,先确认 session 上没有全局占位 Content-Type。
  • 如果同时带文件和 JSON 字段,文件走files=,JSON 字段建议放进data=的字符串里并指定Content-Type: application/json,或者放到 form 字段里,但不能既想传 JSON 又想走 multipart 还指望服务端自动识别。

5.3 一个容易被忽略的坑:dict 里嵌 dict

补充一个很常见的坑。当 payload 本身不是扁平 dict,而是嵌套结构时,用data=发送会出现更奇怪的现象:

payload = { "pageNo": 1, "filter": { "name": "张三", }, }

requests 处理data=payload时,会尝试把内层 dict 转成字符串,结果 body 变成:

pageNo=1&filter={'name': '张三'}

那串{'name': '张三'}出现在表单值里,虽然 URL 编码后能传输,但服务端如果用 JSON 解析,遇到{可能反而能被识别为对象开始,然后遇到单引号就报Unexpected character。这种问题比扁平 dict 更隐蔽,因为它看起来确实有一部分 JSON 的影子。

如果你要发的是嵌套 JSON,老老实实用json=payload,它是唯一稳妥的路径。手动拼 JSON 字符串也可以,但字典里所有字符串都得自己加双引号,容易出错。

5.4 顺手封一个通用发送函数

排查完这个问题后,我把公司内部项目里的 POST 发送逻辑统一封装了一下,核心思路特别简单:默认发 JSON,遇到确需发表单的接口单独走一个分支。

import json import requests def post_json(url, payload, timeout=10): return requests.post( url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, timeout=timeout, )

这里我用data=手动构造,而不是json=payload,其实是想把编码选择权握在自己手里。对绝大多数场景来说,json=payload已经足够可靠,但如果团队里有人需要日志可读、或者要统一控制 UTF-8,手动构造反而更直白。封装好后,后续所有对接都走同一个入口,再遇到类似报错,只需排查一个函数而不是十几个脚本。

实际操作中我的体会是:凡是报错信息里有Unrecognized token、Unexpected character这类字眼,先别急着怀疑中文编码,先把请求体原样打出来看一眼,确认它到底是什么格式。这个动作 30 秒就能完成,却能把排查方向直接掰正。等格式和 Content-Type 都对齐之后,如果还有中文乱码问题,再回头查 charset、网关转码、中间件配置,那时候才是真正处理编码问题的时候。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询