1. 405不是网络不通,是服务器在说“方法不行”
做Android开发的人,十有八九都被405这个状态码折磨过。刚入行那会儿,我遇到POST请求返回405,第一反应是检查网络权限、检查URL对不对、检查是不是没加联网权限,结果折腾半天,发现跟这些都无关。
405的全称是405 Method Not Allowed,翻译成人话就是:服务器收到了你的请求,也识别出了你要访问的资源,但你这个HTTP方法(GET、POST、PUT、DELETE这些)不在允许名单里。打个比方,你走到一家餐馆,店是开着的(服务器在线),你也走到了正确地址(URL路径没错),但服务员告诉你:“我们这儿只做外卖,不接待堂食。”你非要坐下来点菜,对方就给你一个405。
这种拒绝是非常明确的,跟404那种“资源不存在”完全两码事。404是“你找的地方不对”,405是“地方对了,但你用错了方式”。很多Android开发者在排查这个问题时,习惯性地往网络层、权限层去深挖,方向一开始就偏了。
我见过不少项目里,前端Android用HttpURLConnection或者OkHttp发POST,日志里明明打印了请求已发出,却收到405,于是一遍遍检查URL是不是写错了,甚至怀疑是机房网络策略拦截。实际上,绝大部分405都出在“客户端发出的请求方式和服务端路由定义的方式不一致”这个点上。要理解这一点,你得先弄清HTTP协议里状态码的设计逻辑,以及服务端框架处理请求时到底比对的是什么。
这篇文章会把我在实际开发中踩过的405相关的坑、排查思路和最终解决方案完整梳理一遍,适合正在被405折磨的Android开发,也适合后端同学了解客户端的排查视角。内容不会只停留在“哦,你改成POST就好了”,而是把触发405的各种隐蔽场景都挖出来,一步步告诉你为什么会出现,以及怎么定位。
2. Android里发POST的几套常用姿势,先别急着说“我发的就是POST”
很多人排查405的第一步就卡住了:代码里明明写了request.method = "POST",为什么服务端还说方法不允许?这里有个很容易被忽略的事实:你脑子里以为的POST,和你实际发出去的请求,可能根本不是同一个东西。
2.1 HttpURLConnection的坑:setRequestMethod要放在getOutputStream之前
如果你还在用原生的HttpURLConnection,最常见的问题是代码顺序写错。刚入行的同事经常写出这种代码:
URL url = new URL("https://api.example.com/login"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setDoOutput(true); connection.setRequestProperty("Content-Type", "application/json"); // 这里开始写body try (OutputStream os = connection.getOutputStream()) { byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); os.write(input, 0, input.length); }这段写法本身没问题,setRequestMethod("POST")确实在发送请求之前就设置了。但有个隐蔽的细节:如果你在调用getOutputStream()之后再去设置请求方法或者请求头,很多Android版本的实现会直接忽略,甚至抛异常。还有一个特别容易被忽略的点——setRequestMethod只能调用一次,而且必须在connect()之前。有些同事把setRequestMethod写在一个公共的网络工具类里,实际调用却在连接建立之后,那这个设置就完全不生效,请求会以默认的GET方式发出去,服务器自然给你一个405。
2.2 OkHttp的Builder模式:方法名写错是最低级的坑
现在大部分项目都用OkHttp,写起来比HttpURLConnection舒服不少:
val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build() val request = Request.Builder() .url("https://api.example.com/login") .post(body) // 这里指定POST .header("Content-Type", "application/json") .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { // 网络层失败会走到这里 } override fun onResponse(call: Call, response: Response) { if (response.code == 405) { // 业务层失败会走到这里 } } })OkHttp的API设计得比较直观,.post(body)一写,请求行里的方法就是POST。但问题往往出在“你以为调用了,其实没调用”。比如某些二次封装的网络层,内部用了Request.Builder().method("GET", null)作为兜底,外部传入的POST逻辑被某段异常流程吃掉,请求最终以GET发出,这个在日志里很难看出来,因为打印日志的位置可能只打了URL,没打请求方法。
2.3 Retrofit注解没写对,接口方法默认就是GET
再往上封装一层,很多人用Retrofit。Retrofit的注解规则非常严格,接口方法上没写@POST,默认就是GET:
interface ApiService { // 错误示范:忘了写@POST,Retrofit默认按GET处理 @POST("user/login") suspend fun login(@Body request: LoginRequest): BaseResponse<LoginResult> }上面这个例子是写对了的,但如果你把@POST写成了@GET,或者干脆漏掉注解,请求发出去就是GET。服务端路由只注册了POST入口,GET进来就直接405。这种错误在代码Review时特别难发现,IDEA也不会给你任何编译期提示,只有联调时被打个405回来才暴露。
2.4 用curl或者Postman先独立验证:判断是客户端还是服务端的问题
排查405的第一步,永远不是翻客户端代码,而是先用一个与客户端完全无关的工具,单独发一次同样的请求。我自己的习惯是:
curl -X POST https://api.example.com/user/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"123456"}'如果curl能正常返回,说明服务端没有问题,问题一定出在客户端。如果curl同样返回405,那就去服务端日志里看路由配置。这个动作能把排查范围缩小一半以上,可惜很多人第一步就跳过了,直接在自己代码里翻来翻去。
3. 真正让服务器返回405的七类穷因与对应解法
curl验证完之后,问题基本锁定在客户端请求和服务端路由之间的“契约不一致”上。但这层不一致具体是怎么产生的?我把这几年碰到过的405场景归了类,每一类都有对应的解法。
3.1 服务端路由只注册了POST,但客户端实际发出的不是POST
先回到基础:HTTP请求的请求行长这样:
POST /user/login HTTP/1.1 Host: api.example.com Content-Type: application/json Content-Length: 46如果这里的POST变成了GET,或者PUT、PATCH,而服务端这段路由只写了@PostMapping("/user/login")(Spring Boot)或者router.post("/user/login", handler)(Express/Ktor),那必然得到405。
这种情况最常见的发生点:用了HTTP代理或者拦截器,不小心把请求方法改写掉了。比如某些统一封装的网络层,为了做缓存或者日志上报,会在拦截器里把请求复制一份,用request.newBuilder().method("GET", null).build()来生成一个“用于打印日志的副本”,结果不小心把改写后的请求发出去了。这种错误非常隐蔽,日志打出来的还是POST,但线上的请求就是GET。
排查这类问题,唯一可靠的手段是抓包看请求行。你可以用Charles、Fiddler这类工具,或者直接在OkHttp的拦截器里把request.method()和request.url()一起打到日志里:
class LoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() Log.d("Network", "method=${request.method} url=${request.url}") return chain.proceed(request) } }3.2 路径写错了:请求落到了另一个Controller上
有的服务端框架(特别是Spring MVC),路由匹配是按精确路径来的,/user/login和/user/login/都不是同一个路径,更别说/User/Login这种大小写不一致的写法。如果你的URL拼写有误差,请求可能落到了一个只接受GET的接口上,服务端返回405而不是404——因为路径前缀匹配到了某个Controller,只是方法不匹配。
真实案例:某个项目后端同时提供了GET /api/v1/user/info和POST /api/v1/user/update,Android端一个同事做接口联调时,把update接口的路径写成了GET /api/v1/user/update,服务端那套基于路径前缀的鉴权过滤器先过滤了一遍,然后路由分发时发现这个路径只注册了POST入口,于是返回405。
解法很简单:把URL和服务端接口文档逐字符比对一次,特别是末尾有没有斜杠、大小写是否一致、路径参数位置对不对。注意,URL里拼了中文或者空格但没有做URL编码,也可能导致路径匹配错乱,这类情况在日志里看URL还挺正常,实际服务端解析时已经变了。
3.3 服务端允许POST,但Content-Type被网关或框架拦了
这个场景非常反直觉,我当年定位了很久。某个接口在Postman里用application/json发POST一切正常,但Android端用OkHttp发POST就405。两边URL一模一样,请求方法一模一样,唯一的区别是Content-Type。
后来查了一圈才发现:服务端用的是Spring Security + CSRF防护,或者某些云WAF策略,对POST请求要求必须带特定的Content-Type头,比如application/x-www-form-urlencoded、multipart/form-data。Android端发的是application/json; charset=utf-8,网关层一看Content-Type不在白名单里,直接拦截返回405。
这种问题的关键特征是:用浏览器访问或Postman能通,换到Android就405。因为Postman往往会在界面上帮你把Header按你选的Body类型自动填好,而Android代码里如果漏了header("Content-Type", "application/json"),OkHttp默认不放这个头,某些服务端框架就当作“非法请求”处理。
处理方案:确认服务端到底要求什么Content-Type,然后代码里明确设置:
val request = Request.Builder() .url(url) .post(body) .header("Content-Type", "application/json") .build()3.4 重定向之后的POST变成了GET
这个坑在登录场景里特别常见。服务端某些接口做了HTTP到HTTPS的301重定向,或者从旧域名跳到新域名。按照HTTP规范,301/302重定向时,如果原始请求是POST,浏览器和多数HTTP客户端会自动将重定向后的请求改成GET,而且会丢掉请求body。
OkHttp默认是跟随重定向的,followRedirects默认是true。这意味着:你的代码发的是POST,服务端返回301,OkHttp自动跟随,第二次请求变成了GET,落到重定向后的URL上。如果那个URL只接受POST,就会返回405。
这个问题排查起来更隐蔽,因为你在OkHttp的日志里看到的第一条请求确实是POST,但看不到后续跟随重定向时自动发出的GET请求。我当时是抓包才发现异常。
解法有两个方向:
- 在构建OkHttpClient时关闭自动重定向,自己处理:
val client = OkHttpClient.Builder() .followRedirects(false) .followSslRedirects(false) .build()- 如果业务必须跟随重定向,那就确保重定向的目标URL支持同样的请求方法,或者让后端直接改掉重定向配置。
3.5 WebView里的表单POST:少了Method声明
有些项目会在WebView里内嵌H5页面,H5页面需要通过表单方式POST数据到服务端。这时候要注意,H5的<form>标签如果不写method="post",默认就是GET。这不是Android代码的问题,但Android开发者排查时经常会忽视WebView相关页面的影响。
还有一种是WebView加载本地HTML,HTML里的JS用XMLHttpRequest发POST,但设置了跨域请求,服务端没配CORS,请求在预检(OPTIONS)阶段就被拒了。有的服务端框架对OPTIONS请求会返回405,Android端看到的最终错误也是405,但根因是跨域配置缺失。
遇到这种情况,不能只盯着Android原生代码,要把逻辑链路扩到WebView里运行的H5代码,确认form表单和ajax请求的method究竟写的是什么。
3.6 服务端API设计本身有问题:把POST接口限定成了其他方法
还有一种情况,服务端框架的用法本身有问题。比如Spring Boot的@RequestMapping不指定method时,默认匹配所有方法;但如果你写了@RequestMapping(value = "/user/login", method = RequestMethod.GET),那这个接口就只能GET,不写POST。Android端不管怎么努力发POST,都是405。
这类问题需要后端配合修改路由定义,但在等待后端修复时,客户端可以临时用一个策略绕过去:换一个同功能但方法匹配的接口,或者和服务端确认是否有兼容的GET接口。遇到这种情况要留好截图和请求日志,方便沟通时作为证据。
3.7 Android 9之后默认禁止明文HTTP,引发的间接405
Android 9(API 28)开始,默认禁止明文HTTP流量。如果你的接口是http://开头,OkHttp会直接抛异常,或者在某些配置下被系统网络栈拦截,返回的错误表现五花八门,其中一个常见表现就是非标准的405。
严格来说这不算真正的服务端405,但确实会出现在“为什么我的POST返回405”的搜索里。排查方法:看一眼请求URL是不是http开头,是的话要么让后端升级HTTPS,要么在AndroidManifest里配置android:usesCleartextTraffic="true",或者配置网络安全策略:
<application android:usesCleartextTraffic="true" ... >不过这个解法有安全风险,生产环境不建议全局放开。
4. 从报错到定位:405问题的完整排查链路
很多开发者遇到405就慌了,这里给一套我压箱底的排查顺序,按这个顺序走,基本15分钟内能定位80%的405问题。
4.1 第一步:确认异常的确切类型和堆栈
看到405先别急,先分清楚是网络层抛出来的异常,还是服务器返回的HTTP响应。这俩定位方向完全不同:
- 如果是
IOException: unexpected end of stream或者ConnectException,那跟405八竿子打不着。 - 如果是
Response.code()等于405,也就是服务端正常返回了,那已经是业务层的问题了。
如果用的是OkHttp + Retrofit,Retrofit的HttpException里就有code()方法,拿到405之后,把response.errorBody()?.string()打印出来,服务端通常会在body里写一段错误描述,比如{"message":"Method Not Allowed"}或者更具体的"POST not supported for /user/login"。这段body信息量极大,很多人排查405时只看状态码不看body,这是巨大的浪费。
代码里可以这样加日志:
if (response.code() == 405) { val errorBody = response.errorBody()?.string() Log.e("Network", "http code=405, body=$errorBody") }4.2 第二步:抓包看请求行和响应头
如果body信息不够,就要抓包看原始请求。Android抓包相比PC端有一点麻烦:Android 7.0(API 24)之后,App默认不信任用户安装的CA证书,所以用Charles抓HTTPS包的经典方案会失效。这个坑很多人踩过,有几种应对办法:
- 在App的
network_security_config.xml里设置信任用户证书,仅限Debug包。 - 用root设备把证书装到系统证书目录。
- 用Frida或objection做SSL Pinning绕过。
- 最简单的方式:在OkHttp拦截器里打印完整的请求和响应信息。
拦截器输出这一步其实比Charles更直接,因为它能看到App进程里最真实的请求内容,不会受代理抓包时的影响:
class HttpLoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val requestBody = request.body println("REQUEST: ${request.method} ${request.url}") println("REQUEST HEADERS: ${request.headers}") if (requestBody != null) { val buffer = Buffer() requestBody.writeTo(buffer) println("REQUEST BODY: ${buffer.readUtf8()}") } val response = chain.proceed(request) println("RESPONSE: ${response.code} ${response.message}") return response } }看到请求行之后,重点确认三件事:方法是不是POST、路径是不是完全匹配接口文档、Content-Type是不是服务端要求的。
4.3 第三步:用curl复现,隔离客户端变量
抓包拿到请求信息后,把相同内容复制到curl里,在PC上独立跑一次:
curl --location --request POST 'https://api.example.com/user/login' \ --header 'Content-Type: application/json' \ --data-raw '{"username":"test","password":"123456"}'- 如果curl正常,说明服务端没问题,差异在客户端(可能是代理、拦截器、证书等)。
- 如果curl同样405,说明服务端本身对该路径的POST就是不支持的,别再折腾Android代码了,直接找后端对路由。
4.4 第四步:检查服务端日志、确认路由表
和服务端同学沟通时,不要只说“Android发POST返回405”,要把完整信息带上:
- 请求URL和请求方法(抓包截图)
- Content-Type
- 完整的响应body
- 大致时间点,方便后端拉日志
后端在Spring Boot里可以通过RequestMappingHandlerMapping查看所有已注册的路由和方法。如果是Ktor或者Node.js Express,可以直接看路由定义代码。服务端日志里如果有No mapping for POST /user/login(Spring常见的警告日志),那说明这个路径压根没注册POST方法。
4.5 第五步:二分定位法,快速缩小责任方
如果项目链路长——Android端 → 网关 → 微服务 → 数据库——405可能出现在任何一层网关策略上。我常用一个二分法:从客户端直接请求网关入口,确认网关是否放行;再让后端同事绕过网关直连服务,确认服务本身是否正常。哪一环开始返回405,问题就定位在哪一环。
这个过程中要特别留意网关层(比如Nginx、Kong、APISIX)的location配置,有些网关默认只允许GET和HEAD,会直接对POST返回405。
5. 405和404/403/500的分界线:别再张冠李戴
排查过程中我发现,很多人对HTTP状态码的语义边界模糊,导致排查方向从一开始就错了。这里把几个容易混淆的状态码放一起对比一下。
| 状态码 | 含义 | 排查方向 |
|---|---|---|
| 400 Bad Request | 请求格式错误,服务端压根看不懂你的body | 检查参数格式、JSON语法、编码 |
| 401 Unauthorized | 未认证或登录失效 | 检查token、cookie、签名 |
| 403 Forbidden | 已认证但没权限 | 检查角色权限、IP白名单 |
| 404 Not Found | 路径或资源不存在 | 检查URL路径拼写 |
| 405 Method Not Allowed | 路径存在,但HTTP方法不允许 | 检查请求方法、路由方法注册、Content-Type |
| 500 Internal Server Error | 服务端代码执行出错 | 服务端查日志、看堆栈 |
有一个很典型的场景:URL路径写错了,比如少了一个api,结果请求落到了一个不存在的地方,有些网关会返回404,但有些网关配置了通配符路径,会把请求转发到一个兜底Controller上,这个Controller只接受GET,于是返回405。这就是为什么有时候405和404之间的界线那么模糊。遇到这种情况,先把URL路径和服务端路由表逐项比对,路径对了再去纠结方法的事。
6. 预防405的工程化手段:联调前就把它摁死在摇篮里
排查是痛苦的,但405这类问题完全可以靠工程手段提前发现。分享几个我在团队里推行的做法。
6.1 网络层统一封装,强制打印请求方法和URL
把OkHttp的拦截器做成强制性的,所有请求都必须经过统一的错误处理拦截器。这个拦截器除了打印日志,还可以在收到405时直接把服务端返回的错误体抛出来,让调用方能第一时间看到:
class ErrorMappingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) if (response.code == 405) { val body = response.peekBody(1024).string() throw HttpException("HTTP 405: ${chain.request().method} ${chain.request().url} body=$body") } return response } }这样做的价值在于:不让405错误静默地返回给上层业务,而是以一种“一眼能看到完整上下文”的异常形式抛出,开发者在联调阶段就能快速定位。
6.2 接口文档里强制标注Method和Content-Type
很多405问题是前后端对接口契约理解不一致导致的。团队里可以约定:接口文档里每个接口必须标注Method、Path、Content-Type三个字段,缺一不可。Android端生成网络层代码时,直接从文档自动生成Retrofit接口定义,减少手写注解的出错概率。
如果能用OpenAPI(Swagger)规范管理接口,前端可以直接从文档自动生成客户端代码,比如用OpenAPI Generator生成Retrofit2代码,@POST、@GET这些注解全部由工具生成,人肉写错的可能性就基本掐死了。
6.3 集成测试阶段加入“方法校验”用例
在CI/CD流程里加一个简单的接口契约测试:每次后端发版时,自动遍历所有路由,发起一次GET和一次POST请求,校验是否返回预期状态码。如果某个接口只允许POST,那GET进来就必须返回405,如果不返回405反而说明路由配置有兼容性问题。这类用例写起来不复杂,但能把接口契约变更第一时间暴露出来。
6.4 客户端要有“全局405兜底提示”,不要裸奔
生产环境如果还出现405,大概率是后端发生了不兼容的发布。客户端最好有全局的HTTP错误码兜底处理,遇到405时不要展示一个空白的失败,而是给用户一个明确的提示:“服务暂不可用,请稍后重试”,并同步上报到监控平台。这个不需要复杂逻辑,在网络层拦截器里统一处理即可。
写在最后的几个实在建议
做了这么多年Android开发,跟405纠缠的时间不算短,最后说点实实在在的体会。
第一,405大多数时候不是客户端代码问题,而是前后端契约没对齐。遇到405先别急着改代码,先看请求行、看服务端路由表,把责任方搞清楚再动手。我这里说的“责任方”,不是让你甩锅,而是让排查行动有的放矢。
第二,抓包能力是Android开发的必修课。OkHttp拦截器打日志虽然方便,但经过拦截器处理的请求已经是“封装后”的,不是网络上真实传输的报文。真要定位问题,Charles、Wireshark这些工具该会还得会,尤其要搞定Android 7.0之后的证书信任问题,否则一到HTTPS的坑就抓瞎。
第三,沟通时把信息给足。跟后端对405问题,只说“我这边405了”基本等于没沟通。把请求方法、URL、Header、Body、响应信息全部贴出来,后端一眼就能看出问题在哪一行。很多时候我帮同事排查,发现其实是后端自己路由写错了,但客户端这边连个完整的请求日志都没打出来,绕了一大圈。
第四,所有网络请求场景都要考虑重定向。服务器返回302、301不一定是死路,但重定向之后如果你的POST被变成了GET,那405就是大概率事件。OkHttp默认跟随重定向这个行为,在生产环境要想清楚该不该关。
第五,如果你用的是Retrofit + OkHttp这套组合,405的排查优先级应该是:先查接口注解 → 再查路径拼写 → 再查Content-Type → 再查重定向 → 再查网关。按这个顺序走,绝大多数问题都能在半小时内定位。如果这些方向都查不出问题,才需要怀疑是不是OkHttp版本本身有bug——这种情况极少,但也不是零。
希望这篇东西能帮到还在被Android POST 405折磨的人。排查网络问题的关键从来不是背答案,而是建立一套可靠的定位路径,顺着线索一步步逼近真相。405虽然烦人,但搞清楚之后,你会发现它反而是最好处理的一种HTTP错误。