
接手“模拟项目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参数会被编码成usernamezhangsanpassword123456这样的字符串放到请求体里。第二种是JSON请求体适合服务端按JSON字符串收参的接口String json {\username\:\zhangsan\,\password\:\123456\}; RequestBody body RequestBody.create( json, okhttp3.MediaType.parse(application/json; charsetutf-8) ); Request request new Request.Builder() .url(http://host/api/user/login) .post(body) .build();这里重点在于MediaType.parse(application/json; charsetutf-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请求体是usernamezhangsanpassword123456。它按JSON去解析必然失败于是返回400“无法读取请求体”。反过来也一样。你写了一个JSON字符串结果忘了设置Content-Typeokhttp3可能把媒体类型当成text/plain服务端按普通文本读发现不是它要的结构依然400。更隐蔽的一种是“两头都沾”你创建了FormBody却手动加了Content-Type: application/json的Header。服务端看到JSON头真的开始按JSON解析结果请求体还是keyvaluekey2value2解析到一个非法的结构还是会400。这种属于“自相矛盾”排查时最容易让人懵。2.3 用“最小请求”验证Content-Type到底该写成什么当你不知道接口到底收什么格式时别猜。构造一个只有一个必填参数的最小请求分别用JSON体和表单体各发一次看服务端是否换了响应内容。比如先用表单体curl -i -X POST http://host/api/user/login \ -H Content-Type: application/x-www-form-urlencoded \ --data usernamezhangsan再用JSON体curl -i -X POST http://host/api/user/login \ -H Content-Type: application/json \ --data {username:zhangsan}哪个请求的响应不仅仅是400而是变成了参数缺失、字段错误这类更具体的提示哪个就是服务端认可的方式。这个“最小请求法”在排查时能帮你快速划定方向避免在错误方向上修半天。3. 字符编码、URL转义和看不见的字符这些坑藏在细节里3.1 charsetUTF-8到底写在哪里才有效HTTP消息体本质上是一串字节服务端接收到字节后必须知道这些字节用什么字符集解码。这个信息主要来自两个地方一个是Content-Type里的charset参数另一个是请求体里如果有encodingUTF-8这样的标识或者JSON结构本身默认按UTF-8解析。如果你用FormBodyokhttp3内部会按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; charsetutf-8。3.2 参数拼接的隐藏杀手特殊字符被二次编码手动拼接URL参数是自动化脚本里非常常见的操作比如String url http://host/api/user/search?name name page page;这个写法在手工测试时可能没问题因为传进来的name恰好是普通字母。但一旦name变成了张三roleadmin或者包含了#、、这些保留字符服务端解析查询字符串时就会把参数切错。轻则参数对不上重则被解析成非法结构返回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。自动化脚本里还有一个很容易忽略的HeaderUser-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变GETokhttp3默认是会自动跟随重定向的默认值followRedirectstrue。跟随重定向时HTTP规范规定301、302重定向会把POST方法改成GET307、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日志和报文原文替你说话。