接手“模拟项目X”的自动化回归时,我第一件事就是调用户中心的登录接口。本以为拿现成脚本改改就行,结果所有post请求发出去,响应码稳稳定在400。你问我到底哪错了?服务端只回一个“Bad Request”,多一个字都不给;你翻代码、看参数、对照文档,表面上一切正常。后来我排查了整整两天,才发现问题根本不在我以为的地方。这类“okhttp3 post请求结果一直400”的场景,在java自动化里出现频率极高,而且大概率你自己就能找到原因,只是缺一套系统性的拆解方法。
这篇文章就把我自己的排查链路完整还原出来,从状态码本身的语义拆开,到okhttp3构造请求时的常见坑,再到字符编码、签名校验、网关重定向这些隐性因素,最后给一条可以直接照抄的日志排查方案。无论你是刚学接口自动化,还是已经跑了一段时间但被400卡到怀疑人生,按这个思路走一遍,基本都能定位到问题层面。
1. 400这个状态码,藏在“语法错误”背后的两层语义
1.1 先弄明白400在协议层面说的是什么
在HTTP协议里,400属于“客户端错误”分类。它的字面意思是“服务器无法理解这个请求”。注意,这里的“理解”不是业务逻辑层面的对错,而是更底层的“能不能解析”。服务端收到请求后,先看请求行、再看消息头、最后读消息体,任何一个环节无法按约定解析,就会直接判定为400。
举一个生活化的例子。你去食堂打饭,说了句“我要一份宫保鸡丁”,师傅听懂了,这就相当于一个正常的请求,服务端会正常处理。但如果你说的是“宫保鸡丁一份我要”,语法乱了,师傅要问你到底要什么。如果这样说:“@#¥%……&”,师傅直接给你一个“你说啥”的表情。400就是服务端这个“你说啥”的表情。
问题在于,现代接口服务不是只有一个食堂师傅。请求会经过网关、安全过滤器、参数解析器、业务控制器等多层处理者,任何一层觉得“我听不懂”,都可能直接把请求毙掉,并且用400统一回应。所以这个状态码背后,藏着完全不同的两类问题。
1.2 自动化里为什么高频撞上400
手工测试用接口调试工具时,工具会自动帮你补充很多HTTP细节,比如Content-Type、Content-Length、Accept、User-Agent这些头。浏览器发请求更是如此,页面框架早就把这些信息写好了。你表面上只是在填参数、点发送,背后其实有一整个环境在帮你把请求打扮成“标准模样”。
但Java自动化里用okhttp3时,一切都靠自己。你手动构造RequestBody,手动指定Content-Type,手动拼接URL参数,手动决定要不要带某个Header。这些环节任何一个和“服务端的预期”错开,400马上就来。
另外自动化脚本往往不是给一个人用的,而是要覆盖登录、下单、查询等一堆业务场景。不同场景对应的接口契约可能完全不同,有的收表单、有的收JSON、有的要求签名头。脚本批量跑起来后,一个接口的400可能只是表象,真正的问题是通用的请求构造函数没有做差异处理。
1.3 先给自己列一个400检查清单
做任何排查之前,我建议先把下面这张清单存在手边。后面每一步排查,都是往清单的某一项里填证据。
| 检查维度 | 重点内容 | 典型表现 |
|---|---|---|
| 请求头 | Content-Type、Accept、Content-Length | 头缺失、头冲突 |
| 请求体 | 格式、字段名、字段顺序、类型 | JSON解析失败、参数绑定失败 |
| 字符编码 | UTF-8声明、URL转义、不可见字符 | 中文乱码、%0A干扰 |
| URL | 特殊字符、重复编码、参数顺序 | 参数错位、签名不一致 |
| 中间层 | 网关、WAF、重定向、超时 | 合法请求被拦截 |
| 服务端逻辑 | 签名校验、时间戳、防重放 | 业务校验直接400 |
这张清单看起来简单,但能防止你像我第一次那样,抓到参数就埋头狂改,改了一下午发现和参数根本没有关系。
2. 请求体构造方式和Content-Type不匹配,是最大的一坨“锅”
2.1 okhttp3里三种常见POST姿势
okhttp3构造POST请求,绝大多数代码逃不出三种方式。
第一种是表单提交,适合服务端按application/x-www-form-urlencoded收参的接口:
FormBody formBody = new FormBody.Builder() .add("username", "zhangsan") .add("password", "123456") .build(); Request request = new Request.Builder() .url("http://host/api/user/login") .post(formBody) .build();这种写法里,okhttp3会自动帮你设置Content-Type为application/x-www-form-urlencoded,参数会被编码成username=zhangsan&password=123456这样的字符串放到请求体里。
第二种是JSON请求体,适合服务端按JSON字符串收参的接口:
String json = "{\"username\":\"zhangsan\",\"password\":\"123456\"}"; RequestBody body = RequestBody.create( json, okhttp3.MediaType.parse("application/json; charset=utf-8") ); Request request = new Request.Builder() .url("http://host/api/user/login") .post(body) .build();这里重点在于MediaType.parse("application/json; charset=utf-8")。很多人会漏掉charset部分,或者直接写application/json。绝大多数情况下这没事,但一旦遇到对charset敏感的老旧服务端,这就成了400的源头。
第三种是文件上传,也就是MultipartBody。它的Content-Type是multipart/form-data,并且必须带上自动生成的分隔符boundary。这种场景的400通常不太常见,但如果接口对接时文档写的是表单,实际实现却是multipart,也会出现解析失败。
2.2 服务端期望和实际发送相反时会发生什么
这里发生的错配,是自动化里最典型、也最好复现的一类400。
如果服务端期望的是一个JSON体,比如后端代码写了接收一个对象参数的解析逻辑,而你用FormBody发过去,服务端收到的请求头是application/x-www-form-urlencoded,请求体是username=zhangsan&password=123456。它按JSON去解析,必然失败,于是返回400“无法读取请求体”。
反过来也一样。你写了一个JSON字符串,结果忘了设置Content-Type,okhttp3可能把媒体类型当成text/plain,服务端按普通文本读,发现不是它要的结构,依然400。
更隐蔽的一种是“两头都沾”:你创建了FormBody,却手动加了Content-Type: application/json的Header。服务端看到JSON头,真的开始按JSON解析,结果请求体还是key=value&key2=value2,解析到一个非法的结构,还是会400。这种属于“自相矛盾”,排查时最容易让人懵。
2.3 用“最小请求”验证Content-Type到底该写成什么
当你不知道接口到底收什么格式时,别猜。构造一个只有一个必填参数的最小请求,分别用JSON体和表单体各发一次,看服务端是否换了响应内容。
比如先用表单体:
curl -i -X POST http://host/api/user/login \ -H "Content-Type: application/x-www-form-urlencoded" \ --data "username=zhangsan"再用JSON体:
curl -i -X POST http://host/api/user/login \ -H "Content-Type: application/json" \ --data '{"username":"zhangsan"}'哪个请求的响应不仅仅是400,而是变成了参数缺失、字段错误这类更具体的提示,哪个就是服务端认可的方式。这个“最小请求法”在排查时能帮你快速划定方向,避免在错误方向上修半天。
3. 字符编码、URL转义和看不见的字符:这些坑藏在细节里
3.1 charset=UTF-8到底写在哪里才有效
HTTP消息体本质上是一串字节,服务端接收到字节后,必须知道这些字节用什么字符集解码。这个信息主要来自两个地方:一个是Content-Type里的charset参数,另一个是请求体里如果有encoding="UTF-8"这样的标识,或者JSON结构本身默认按UTF-8解析。
如果你用FormBody,okhttp3内部会按UTF-8对参数值做百分号编码,中文字符会被转成%E5%BC%A0%E4%B8%89这种形式。这种情况下,即使你不写charset,服务端按UTF-8解码也能还原出中文。所以表单场景反而很少遇到中文乱码。
最容易出问题的,是手拼JSON字符串然后包成RequestBody。有的人会写成这样:
String json = "{\"name\":\"张三\"}"; RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));Content-Type里没有charset。一些服务端框架默认按UTF-8处理,没问题。但某些中间件会按ISO-8859-1去解,中文直接变成乱码,再一校验长度、格式,就直接400。所以稳妥的做法是写全:application/json; charset=utf-8。
3.2 参数拼接的隐藏杀手:特殊字符被二次编码
手动拼接URL参数是自动化脚本里非常常见的操作,比如:
String url = "http://host/api/user/search?name=" + name + "&page=" + page;这个写法在手工测试时可能没问题,因为传进来的name恰好是普通字母。但一旦name变成了张三&role=admin,或者包含了#、+、=这些保留字符,服务端解析查询字符串时就会把参数切错。轻则参数对不上,重则被解析成非法结构返回400。
okhttp3提供了HttpUrl构建器,会自动处理转义:
HttpUrl url = HttpUrl.parse(baseUrl).newBuilder() .addQueryParameter("name", name) .addQueryParameter("page", "1") .build();这个方案的逻辑是:你传入原始值,构建器负责转义。但要注意,如果你传入的值已经转义过,比如name本身是%E5%BC%A0,构建器会把%再转成%25,服务端解码后得到的是字符串“%E5%BC%A0”,而不是“张”。这就是“二次编码”。
我的经验是:所有参数一律传原始值,让HttpUrl统一处理一层编码,绝不在外面先拼好编码后的字符串再塞进去。
3.3 肉眼看不见的换行、空格和BOM
在自动化脚本里,参数值经常来自配置文件、Excel表、解密接口返回值。这些来源非常容易夹带看不见的字符。
最常见的是换行符。数据从Excel或加密工具复制出来时,末尾经常会带一个\n。肉眼根本看不到,但在请求体里,它就是活生生一个字符。FormBody会把换行编码成%0A,服务端做正则校验、长度校验时,这个字符直接把校验打挂,返回400。
另一个难缠的是BOM头,也就是\uFEFF。有些文件用带BOM的UTF-8格式保存,Java读取后,第一个字符肉眼看不见,但已经混进了参数值。
排查这个问题最直接的办法,是对请求体做十六进制打印。一个正常字母“A”的十六进制是41,如果前面出现了EF BB BF,那就是BOM;如果末尾出现0A,那就是换行。在拦截器里加上字节打印,能让你从“看着没问题”变成“证据确凿”。
4. 服务端和中间层也在“掺和”:签名、网关、重定向全是嫌疑犯
4.1 网关把“正常请求”弹回400的几种姿势
请求发出后,真正处理它的是服务端背后的完整调用链。最外层的网关和反向代理,通常负责检查请求格式是否“物理正确”。比如请求头里写的Content-Length和实际请求体长度不一致,或者请求体还没传完就被判定超时,网关会直接回400。
自动化脚本里还有一个很容易忽略的Header:User-Agent。某些安全网关会对没有User-Agent,或者User-Agent明显是“非浏览器”字符串的请求做拦截。你可能会看到400,也可能看到403,但很多网关实现得比较“含蓄”,统一返回400。解决办法很简单,在请求里带一个常见的浏览器UA字符串,往往就过了。
再比如CSRF这类的Origin、Referer校验。如果服务端要求请求必须带Origin头,并且Origin要和目标域名匹配,你缺了这个头,部分校验逻辑会以400收场。
4.2 签名校验失败后返回400而不是401的接口设计
你可能会觉得,签名失败应该返回401或者403,怎么可能是400。但真实世界里,很多内部服务接口的设计恰恰是:鉴权失败返回401,签名无效返回403,而“请求内容整体非法”包括“时间戳偏差过大”、“nonce重复”、“签名格式不对”,统一归到400。
这个设计逻辑很简单:不告诉攻击者具体哪里错了,减少信息暴露。但对自动化开发者来说,这就是灾难。因为你会看到400,却完全不知道是签名算法坏了,还是时间戳差了3秒。
排查时,如果接口协议里明确写了“appId、timestamp、nonce、sign”这几个字段,并且你的请求头、请求体看起来都正常,那就优先怀疑签名链路。把服务器返回的时间戳和本地时间戳对齐,确认偏差;再把参与签名的所有Header、参数按规则文档逐字比对,包括大小写、空格、顺序。很多签名案例最后都是因为多了一个空格导致签名不匹配。
4.3 302/307重定向导致POST变GET
okhttp3默认是会自动跟随重定向的,默认值followRedirects=true。跟随重定向时,HTTP规范规定301、302重定向会把POST方法改成GET,307、308则保持原方法不变。
问题就出在这。你的脚本发送了一个POST请求,地址因为某种原因发生了302跳转,比如从HTTP跳到HTTPS。客户端跟着重定向之后,请求的“身份”已经从POST变成了GET。原本在请求体里的参数全部消失,服务端如果只接受POST,拿到这个GET请求就返回400。
整个过程在拦截器里看,第一行是“POST http://...”,第二行变“GET https://...”,第三行状态码400。如果你没打日志,只看最终结果,会让你百思不得其解。
遇到这种情况,我再单独说明:能直接改配置地址就优先直接走目标地址,避免中间跳转;不能改,就把自动跟随关掉,在业务层自己处理重定向,按原方法重新构造请求再发一次。
5. 完整排查链路:把请求从构造到响应逐层留证据
5.1 用拦截器把原始报文打全
排查400最关键的一步,是让你自己看见“代码实际发出的HTTP报文长什么样”。教条式的检查没有用,必须看真实数据。
为okhttp3客户端加一个日志拦截器,我建议放到addInterceptor里,而不是addNetworkInterceptor。前者能看到应用层最终发送的内容,更贴近你代码里写的东西。参考代码:
OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(chain -> { Request request = chain.request(); System.out.println("====> " + request.method() + " " + request.url()); for (int i = 0; i < request.headers().size(); i++) { String name = request.headers().name(i); System.out.println("HEADER " + name + ": " + request.headers().value(i)); } RequestBody body = request.body(); if (body != null) { okio.Buffer buffer = new okio.Buffer(); body.writeTo(buffer); String bodyStr = buffer.readUtf8(); System.out.println("BODY_TEXT: " + bodyStr); System.out.println("BODY_HEX: " + toHex(bodyStr.getBytes(StandardCharsets.UTF_8))); } Response response = chain.proceed(request); ResponseBody responseBody = response.peekBody(1024 * 1024); System.out.println("<==== " + response.code() + " " + response.message()); System.out.println("BODY_RESP: " + responseBody.string()); return response; }) .build();里边的toHex方法可以自己写一个:
private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02X ", b)); } return sb.toString(); }之所以连十六进制一起打,是为了抓不可见字符。你肉眼看不到的0A、EF BB BF,在十六进制里一目了然。
5.2 用其它语言或调试工具做同请求对比
拿到报文后,下一步是把同样的请求原样用其它方式发一遍。这里我只说原则,不点名某个具体工具:你随便找一种“能手动编辑请求头和请求体”的调试工具,或者直接用命令行发。
命令行的好处是最大程度减少“工具帮你自动补头”的干扰。用命令行发送和okhttp3完全一样的URL、Header、Body。如果命令行也返回400,说明问题在请求数据本身;如果命令行返回200,而okhttp3还是400,差异一定藏在某些自动生成的头里,比如Content-Length、Accept-Encoding、User-Agent,或者okhttp特有的重定向行为。
这种“同请求、不同客户端”的对照,是定位问题层面的黄金方法。
5.3 针对字节级别的检查手段
很多400是字节层面而非逻辑层面出的问题,尤其是中文环境下的自动化。建议在拦截器打出请求体后,直接把转义后的字符串也打出来:
- FormBody的场景,打印
URLDecoder.decode(bodyStr, "UTF-8"),看参数还原后是否包含%0A、%0D、%20这些异常值。 - JSON的场景,用JSON解析器解析一下
bodyStr,看能否成功解析。如果解析失败,说明请求体本身就是非法JSON。 - 对参数里的可见字符做
trim()前后对比,判断是不是首尾空格造成的校验失败。
这些操作执行完,基本能排除掉80%的常规问题。
5.4 服务端日志和网关日志怎么配合
如果你能接触到服务端,看日志时不要只看业务异常,重点看框架抛出的解析异常。比如“读取请求体失败”、“请求体格式不合法”、“必要的请求参数缺失”这类信息,会直接告诉你问题发生在请求体的哪一层。
网关日志则更偏“信息量少、响应快”。它会记录客户端IP、请求路径、状态码和处理时间。如果网关日志显示处理时间极短,比如几毫秒,说明请求根本没进入业务层,是在网关就被判了“语法非法”。这时候就别再查业务逻辑了,重点查Header的一致性、请求体长度、User-Agent这些物理层信息。
6. 两个我印象深刻的400案例复盘
6.1 案例一:URL参数里多了一个看不到的换行符
当时是模拟项目X里一个用户搜索接口,请求参数是从Excel读取的,第一次读取后缓存在一个Map里。脚本跑起来,搜索接口就一直400。我看参数名、参数值、URL拼接逻辑,全都合理,于是加了拦截器打印Hex。
Hex结果显示,keyword参数的值末尾多了一个0A。我顺着数据来源查,发现Excel单元格在另一个同事预处理时被追加了换行符,而读取代码没有做trim。这个换行符在URL里被转义成%0A,服务端搜索关键词做格式校验时直接拒收。
修好之后我还在拦截器里加了一行:把所有参数值做一个常规清洗,去掉首尾控制字符。从那以后,这类问题再没出现过。
6.2 案例二:302重定向把POST改成GET后返回400
另一个项目里,外部系统提供的接口地址是HTTP,但实际会对请求做一次302跳转。自动化脚本里写的是POST登录请求,okhttp3默认跟随重定向,第一次发送返回302,第二次跟随过去时请求变成GET,服务端没有GET路由,最终返回400。
刚开始我以为是参数问题,因为最终响应的URL看起来和我发的URL一模一样,只是多了层跳转。后来在拦截器里看到第二次请求的method变成了GET,这才反应过来。修复方式很简单:把接口地址的协议直接配置成HTTPS,避免中间跳转;同时给客户端关闭自动跟随重定向,在业务层单独做了一次原方法重发。
这个案例给我最大的启发是:400不一定是“参数错了”,也可能是“你的请求在传输过程中变了形”。看问题不能只看最后一跳,要把整个请求的生命周期完整打出来。
排查okhttp3的post请求返回400,说穿了就是三件事:看清服务端要什么,看清你实际发了什么,看清中间层改了没有。把这三件事用日志固定下来,大多数问题都会自己浮出水面。最后再分享一个原则:别相信“看起来没问题”,要让Hex日志和报文原文替你说话。