记一次 RestTemplate 调用接口返回 401 : [no body] 问题复盘

一、问题现象

SP Demo 应用通过 OAuth2 授权码流程完成登录后,在回调接口 /sp-demo/oauth/callback 中使用 RestTemplate 调用本地 /oauth2/token 端点换取 Token。

异常表现:

  • RestTemplate 抛出 HttpClientErrorException,message 为 401 : [no body]
  • 全局异常处理器 GlobalExceptionHandler.handleOAuth2() 断点确认已经执行
  • 服务端确实写入了 JSON 响应体 {"error":"invalid_client","error_description":"客户端认证失败"}
  • 但 RestTemplate 侧始终拿不到 body

二、排查过程

2.1 初始怀疑:OAuth2Exception 未被全局异常处理器捕获

  • 检查发现 OAuth2Service.processToken()client.getClientSecret().equals(clientSecret) 存在 NPE 风险
  • client.getClientSecret() 为 null 时会抛 NullPointerException 而非 OAuth2Exception
  • 修复:改为 !Objects.equals(clientSecret, client.getClientSecret())

但修复后问题依旧。

2.2 尝试增强服务端响应写入

依次尝试了以下方案,均无效

尝试 方案 结果
1 @ControllerAdvice@RestControllerAdvice 无效
2 ResponseEntity 显式设置 .contentType(MediaType.APPLICATION_JSON) 无效
3 直接写 HttpServletResponseresponse.getWriter().write(json) + flush() 无效
4 application.yml 添加 server.error.include-message: always 无效

2.3 最终定位:JDK HttpURLConnection 的 errorStream 问题

RestTemplate 默认使用 SimpleClientHttpRequestFactory,底层是 JDK 的 HttpURLConnection

关键机制:

1
2
3
HttpURLConnection 对 HTTP 响应的处理:
├── 2xx 响应 → body 在 getInputStream()
└── 4xx/5xx 响应 → body 在 getErrorStream()(不是 inputStream!)

RestTemplateDefaultResponseErrorHandler 读取 body 的逻辑:

  1. 判断响应状态为 4xx → 标记为错误
  2. 尝试从 response.getBody() 读取(本质是 inputStream
  3. 此时 inputStream 为空(内容在 errorStream 中)
  4. 最终得到 [no body]

结论:body 没有被服务端”吞掉”,而是 RestTemplate 底层读错了流。

三、解决方案

最终方案:替换为 Apache HttpClient

Apache HttpClient 不区分 inputStream / errorStream,统一从连接中读取响应体:

1
2
3
4
5
6
7
8
9
10
11
12
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpPost tokenRequest = new HttpPost(tokenUrl);
tokenRequest.setHeader("Authorization", "Basic " + credentials);
tokenRequest.setHeader("Content-Type", "application/x-www-form-urlencoded");
tokenRequest.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));

try (CloseableHttpResponse resp = httpClient.execute(tokenRequest)) {
int statusCode = resp.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
// statusCode 和 responseBody 都能正确获取,无论是 2xx 还是 4xx
}
}

备选方案对比

方案 可行性 说明
Apache HttpClient(采用) ✅ 最佳 行为可控,不依赖 Spring 框架行为
RestTemplate + HttpComponentsClientHttpRequestFactory ✅ 可行 让 RestTemplate 底层也用 Apache HttpClient
自定义 ResponseErrorHandler 手动读 errorStream ⚠️ 脆弱 需要反射或 hack,不推荐
OkHttp ✅ 可行 同样不区分 error/input stream,但需额外依赖

四、经验总结

  1. RestTemplate + JDK HttpURLConnection 在 4xx/5xx 时会将信息存入errorStream或丢失响应体
  2. 在需要精确控制 HTTP 请求/响应的场景下,优先使用 Apache HttpClient 或 OkHttp,避免被 Spring 框架的抽象层屏蔽底层细节