原创

Spring Boot RestClient 调用外部接口:超时、404 和 JSON 错误怎样处理

商品不存在、商品服务停机、商品服务返回了坏 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 两个进程。

正文到此结束
评论插件初始化中...
Loading...