Spring Boot RestClient 调用外部接口:超时、404 和 JSON 错误怎样处理
- 发布时间:2026-10-29 20:00:06
- 本文热度:浏览 7 赞 0 评论 0
- 文章标签: Spring Boot RestClient HTTP
- 全文共1字,阅读约需1分钟
商品不存在、商品服务停机、商品服务返回了坏 JSON,不能都在页面上显示“没有商品”。接入外部 HTTP 接口时,真正费事的往往不是发出 GET,而是把这些失败保留下来,并让调用方知道下一步该怎么处理。
下面使用 Spring Boot 3.5.0、JDK 17 和 Maven 3.8.8,写一个同步商品查询接口。应用接收 /catalog/{id},通过 RestClient 请求另一端的商品服务,再返回统一的商品对象。为了不依赖外部平台账号,商品服务由 Python 3 标准库模拟;404、503、429、坏 JSON、延迟响应都能在本机重复制造。
RestClient 从 Spring Framework 6.1 开始提供。这里固定使用 Boot 3.5.0 的依赖组合,不适合直接复制到 Boot 2 项目里。已有同步 MVC 应用可以采用这种调用方式;它会占用当前执行线程等待响应,不能因为调用链写得像函数式 API 就把它当成非阻塞客户端。
先启动一端会故意出错的商品服务
新建 catalog-client-lab 目录,把下面脚本保存到根目录。编号 1 正常返回,其他编号按脚本制造不同故障。它只绑定本机回环地址,商品名和价格都是演示数据。
catalog_stub.py:
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
path = urlsplit(self.path).path
if not path.startswith("/products/"):
self.send(404, b"{}")
return
try:
product_id = int(path.rsplit("/", 1)[-1])
except ValueError:
self.send(400, b"{}")
return
if product_id == 2:
self.send(404, b'{"error":"missing"}')
elif product_id == 3:
self.send(503, b'{"error":"maintenance"}')
elif product_id == 4:
time.sleep(2)
self.send(200, self.product(product_id))
elif product_id == 5:
self.send(200, b"not-json")
elif product_id == 6:
self.send(200, b"")
elif product_id == 7:
self.send(200, b'{"id":7,"name":"wrong type","priceCents":12.5}')
elif product_id == 8:
self.send(429, b'{"error":"slow down"}')
elif product_id == 9:
self.send_response(302)
self.send_header("Location", "/products/1")
self.send_header("Content-Length", "0")
self.end_headers()
elif product_id == 10:
payload = self.product(product_id)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.flush()
time.sleep(2)
try:
self.wfile.write(payload)
except (BrokenPipeError, ConnectionResetError):
pass
elif product_id == 11:
self.send(200, b'{"id":999,"name":"wrong item","priceCents":100}')
else:
self.send(200, self.product(product_id))
def product(self, product_id):
return json.dumps({"id": product_id, "name": "Notebook", "priceCents": 12900}).encode()
def send(self, status, body):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", 31894), Handler)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
在这个目录执行 python3 catalog_stub.py,保持终端打开。另开终端直连上游,确认这个端口真的提供了预期内容:
curl -i http://127.0.0.1:31894/products/1
curl -i http://127.0.0.1:31894/products/2
第一条返回 HTTP 200、编号 1、名称 Notebook、价格 12900 分;第二条返回 HTTP 404。先做这一步,可以把“Java 客户端写错”与“上游根本没启动”分开。
只创建一次客户端,把超时留在配置层
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.0</version><relativePath/></parent>
<groupId>lab</groupId><artifactId>catalog-client-lab</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency></dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/resources/application.properties:
server.address=127.0.0.1
server.port=31893
catalog.base-url=http://127.0.0.1:31894
spring.main.banner-mode=off
src/main/java/lab/catalog/CatalogApplication.java:
package lab.catalog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApplication {
public static void main(String[] args) { SpringApplication.run(CatalogApplication.class, args); }
}
使用 Boot 提供的 RestClient.Builder 构建 Bean,而不是每次查询都重新创建客户端。这里明确选择 SimpleClientHttpRequestFactory,其底层使用 JDK 的 HttpURLConnection,连接超时为 500 毫秒,读取超时为 800 毫秒。换 HTTP 实现时,应重新核对配置对应哪个实现,不能以为设置过一个 timeout 就自动覆盖所有阶段。
示例还关闭了自动重定向。商品接口应按约定直接返回内容,收到 302 时交给状态处理器报错,不悄悄换一个地址继续请求。基础地址来自服务端配置,请求方只能提供商品编号,不能传入一个任意 URL。
src/main/java/lab/catalog/CatalogConfig.java:
package lab.catalog;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration(proxyBeanMethods = false)
public class CatalogConfig {
@Bean
RestClient catalogClient(RestClient.Builder builder, @Value("${catalog.base-url}") String baseUrl) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory() {
@Override protected void prepareConnection(HttpURLConnection connection, String method) throws IOException {
super.prepareConnection(connection, method);
connection.setInstanceFollowRedirects(false);
}
};
factory.setConnectTimeout(Duration.ofMillis(500));
factory.setReadTimeout(Duration.ofMillis(800));
return builder.baseUrl(baseUrl).requestFactory(factory).build();
}
}
读取超时约束的是阻塞读取的等待,不是“整次调用一定在 800 毫秒以内结束”的承诺。读取响应头和读取正文是不同阶段,转换器也可能执行多次读取。若业务需要从开始调用起统一计算截止时间,需要另外设计总时限,不能只调小这里的读取值。
把失败翻译成可判断的结果
内部异常携带本应用对外的状态和错误码。这里没有把上游响应正文直接透传给页面,上游错误页、内部异常栈或供应商字段也就不会混入本应用的成功数据。
src/main/java/lab/catalog/CatalogFailure.java:
package lab.catalog;
public class CatalogFailure extends RuntimeException {
private final int status;
private final String code;
public CatalogFailure(int status, String code) { super(code); this.status = status; this.code = code; }
public int status() { return status; }
public String code() { return code; }
}
Service 的处理顺序很重要:先处理 HTTP 状态,再解码 JSON,最后检查商品字段。404 映射为商品不存在;429 映射为上游繁忙;其他 3xx、4xx、5xx 归为上游 HTTP 错误。映射策略是本例的接口约定,不是所有应用都必须采用同一状态码。
正常 HTTP 状态也不足以证明业务数据可用。空正文、编号不对应、价格字段是小数或负数,都不能构成这里的商品对象。因此先读取 JsonNode,检查类型与范围后再组装 DTO。这样不会把 12.5 分静默截成 12 分,或把另一件商品当成当前查询的结果。
src/main/java/lab/catalog/CatalogService.java:
package lab.catalog;
import com.fasterxml.jackson.databind.JsonNode;
import java.net.SocketTimeoutException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
@Service
public class CatalogService {
public record Product(long id, String name, long priceCents) {}
private final RestClient client;
public CatalogService(RestClient catalogClient) { this.client = catalogClient; }
public Product find(long id) {
if (id < 1) throw new CatalogFailure(400, "BAD_PRODUCT_ID");
try {
JsonNode body = client.get().uri("/products/{id}", id).accept(MediaType.APPLICATION_JSON)
.retrieve().onStatus(status -> status.value() >= 300, (request, response) -> {
int status = response.getStatusCode().value();
if (status == 404) throw new CatalogFailure(404, "PRODUCT_NOT_FOUND");
if (status == 429) throw new CatalogFailure(502, "CATALOG_BUSY");
throw new CatalogFailure(502, "CATALOG_HTTP_ERROR");
}).body(JsonNode.class);
if (body == null || !body.isObject()
|| !body.path("id").isIntegralNumber() || !body.path("id").canConvertToLong() || body.path("id").longValue() != id
|| !body.path("name").isTextual() || body.path("name").textValue().isBlank()
|| !body.path("priceCents").isIntegralNumber() || !body.path("priceCents").canConvertToLong() || body.path("priceCents").longValue() < 0) {
throw new CatalogFailure(502, "CATALOG_BAD_RESPONSE");
}
return new Product(id, body.path("name").textValue(), body.path("priceCents").longValue());
} catch (RestClientException failure) {
boolean timeout = false;
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof SocketTimeoutException) { timeout = true; break; }
}
if (timeout) throw new CatalogFailure(504, "CATALOG_TIMEOUT");
throw new CatalogFailure(502, failure instanceof ResourceAccessException ? "CATALOG_UNAVAILABLE" : "CATALOG_BAD_RESPONSE");
}
}
}
retrieve() 后还需要 body(...) 这样的终结操作,才会真正取回响应。accept 表示希望接收 JSON,并不保证对方一定按约定返回;内容格式仍然要经过转换与校验。
异常捕获检查了整个原因链。连接尚未建立或读取响应头失败,通常会表现为资源访问异常;正文读取失败还可能被转换过程再包一层。只捕获最外层的 ResourceAccessException,容易把真正的正文超时误报为坏 JSON。这里先识别 SocketTimeoutException,剩下再区分连接不可用与转换错误。
src/main/java/lab/catalog/CatalogController.java:
package lab.catalog;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestController
public class CatalogController {
private final CatalogService catalog;
public CatalogController(CatalogService catalog) { this.catalog = catalog; }
@GetMapping("/catalog/{id}")
public CatalogService.Product find(@PathVariable long id) { return catalog.find(id); }
}
@RestControllerAdvice
class CatalogErrors {
@ExceptionHandler(CatalogFailure.class)
ResponseEntity<Map<String, String>> handle(CatalogFailure failure) {
return ResponseEntity.status(failure.status()).body(Map.of("code", failure.code()));
}
}
按结果逐项验收
保持 Python 上游运行,在 Java 项目根目录构建并启动应用:
mvn clean package
java -jar target/catalog-client-lab-1.0.0.jar
第三个终端运行以下请求。Java 应用使用 31893,上游使用 31894,两者不要写反。
curl -i http://127.0.0.1:31893/catalog/1
curl -i http://127.0.0.1:31893/catalog/2
curl -i http://127.0.0.1:31893/catalog/3
curl -i http://127.0.0.1:31893/catalog/4
curl -i http://127.0.0.1:31893/catalog/5
curl -i http://127.0.0.1:31893/catalog/6
curl -i http://127.0.0.1:31893/catalog/7
curl -i http://127.0.0.1:31893/catalog/8
curl -i http://127.0.0.1:31893/catalog/9
curl -i http://127.0.0.1:31893/catalog/10
curl -i http://127.0.0.1:31893/catalog/11
curl -i http://127.0.0.1:31893/catalog/0
编号 1 返回 200 与完整商品。编号 2 返回 404、PRODUCT_NOT_FOUND;编号 3 返回 502、CATALOG_HTTP_ERROR;编号 8 返回 502、CATALOG_BUSY。编号 9 的上游 302 也返回 CATALOG_HTTP_ERROR,不会得到重定向目标商品。
编号 4 在响应头之前等待两秒,编号 10 先发响应头、再等待正文,两者都应得到 504、CATALOG_TIMEOUT。它们验证的是两条实际超时路径,不要求两个请求耗时完全相同。编号 5、6、7、11 分别验证坏 JSON、空正文、小数价格、商品编号不匹配,都应为 502、CATALOG_BAD_RESPONSE。编号 0 在本应用就被拒绝,返回 400、BAD_PRODUCT_ID。
最后在 Python 终端按 Ctrl+C 停掉上游,再查询编号 1:应返回 502、CATALOG_UNAVAILABLE。这时 Java 服务仍能处理请求,失败的是它的依赖,不能用“商品不存在”掩盖。恢复 Python 后再次查询即可成功,无须重启 Java。
这组配置用于小型 JSON 查询。它没有连接池调优、响应体字节上限,也没有自动重试;面对大文件或持续流式数据应换成相应读取方式。特别是将来增加扣款、创建订单等写请求时,不要在这个通用调用层一律自动重试。练习结束后分别停止 Java 与 Python 两个进程。