Java 基础体系 · 第 12/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 网络与 HTTP Client:连接、TLS、超时、流和错误恢复

Java 中一次 HTTP 请求并不是“调用一个 URL,然后得到一个字符串”。它至少经过名称解析、连接建立、TLS 握手、HTTP 编码、响应体传输和资源回收等阶段。任何阶段都可能失败,而且不同阶段的失败具有不同的恢复方式。

Java 25 标准库中,HTTP 客户端主要由 java.net.http.HttpClientHttpRequestHttpResponseBodyHandlerBodyPublisherCompletableFuture 组成。它建立在 Java 的网络、I/O、TLS 和并发抽象之上,因此理解 HTTP Client,必须先区分:

  • 连接:客户端如何找到服务器并建立可复用的传输通道;
  • TLS:如何在连接上验证身份并加密数据;
  • 超时:究竟限制哪个阶段,是否覆盖整个请求生命周期;
  • :响应体何时到达、由谁消费、何时释放连接;
  • 错误恢复:哪些错误可以重试,重试时如何避免重复副作用。

一次 HTTP 请求的分层路径

https://api.example.com/users/42 为例,逻辑路径可以表示为:

sequenceDiagram
    participant App as Java 应用
    participant DNS as DNS
    participant TCP as TCP
    participant TLS as TLS
    participant Server as HTTP 服务器

    App->>DNS: 查询 api.example.com
    DNS-->>App: IP 地址
    App->>TCP: 建立 TCP 连接
    TCP-->>App: 连接成功
    App->>TLS: ClientHello
    TLS-->>App: 证书、协商参数
    App->>TLS: 校验证书并完成握手
    App->>Server: HTTP 请求头和请求体
    Server-->>App: 状态码、响应头
    Server-->>App: 响应体字节流
    App->>TCP: 复用或关闭连接

这张图中的层次不能混为一谈:

  1. DNS 将主机名解析为一个或多个地址。DNS 成功不代表 TCP 一定能连通。
  2. TCP 提供有序、可靠的字节流,但不理解 HTTP,也不保证应用层请求最终成功。
  3. TLS 在 TCP 之上提供加密、完整性和服务器身份认证。HTTPS 就是 HTTP 运行在 TLS 之上。
  4. HTTP 定义请求方法、路径、头部、状态码和消息体。
  5. Java API 决定应用如何构造请求、等待响应、消费响应体和处理异常。

因此,“连接失败”“TLS 失败”“返回 503”和“读取响应体时断开”是四类不同问题,不能统一按“请求失败”处理。


HttpClient 的角色与生命周期

HttpClient 是可复用的 HTTP 客户端配置和执行入口。一个客户端通常保存以下配置:

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .version(HttpClient.Version.HTTP_2)
        .build();

这里:

  • connectTimeout 控制建立连接阶段的等待时间;
  • followRedirects 控制是否自动跟随重定向;
  • version 表示偏好的 HTTP 版本,而不是绝对保证。实际版本还取决于服务器和 TLS/ALPN 协商;
  • build() 生成不可变的客户端配置对象。

一个 HttpClient 可以服务多个请求。规范保证的是客户端提供这些配置和执行语义;连接池大小、空闲连接回收时间、连接复用的具体策略属于实现细节,不应直接依赖某个未公开参数。

通常应复用 HttpClient,而不是每次请求创建一个:

private static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3))
        .build();

复用客户端的原因不是“创建对象很昂贵”这一简单结论,而是同一客户端有机会复用连接、共享连接管理和执行资源。频繁创建客户端可能导致连接池分散、TLS 握手增加以及线程或底层资源管理复杂化。

HttpClient 本身可以并发使用。请求对象 HttpRequest 也是不可变的,可以安全地复用;但请求体发布器是否可重复读取,需要单独判断。


从 URI 到 HTTP 请求

最小的 GET 请求如下:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class BasicHttpClient {
    public static void main(String[] args)
            throws Exception {

        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(3))
                .build();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://example.com/"))
                .timeout(Duration.ofSeconds(5))
                .header("Accept", "text/html")
                .GET()
                .build();

        HttpResponse<String> response =
                client.send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println("HTTP status: " + response.statusCode());
        System.out.println(response.body());
    }
}

编译和运行:

javac BasicHttpClient.java
java BasicHttpClient

前置条件是机器可以访问 example.com,并且本地 Java 运行时具有正常的根证书配置。输出状态码通常为 200,但服务器可能因为网络、策略或重定向返回其他状态,因此示例不能假定固定结果。

HttpRequest 的关键组成是:

  • URI;
  • HTTP 方法;
  • 请求头;
  • 请求体发布器;
  • 单请求超时。

请求头描述元数据,例如:

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/items"))
        .header("Accept", "application/json")
        .header("Authorization", "Bearer " + token)
        .POST(HttpRequest.BodyPublishers.ofString("""
                {"name":"book"}
                """))
        .build();

BodyPublishers.ofString 会发布一个内存中的字符串。对于较大的文件,可以使用:

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/upload"))
        .header("Content-Type", "application/octet-stream")
        .PUT(HttpRequest.BodyPublishers.ofFile(Path.of("payload.bin")))
        .build();

这里的请求体是一个字节流。HTTP 客户端可能通过已知长度发送,也可能使用分块传输,具体取决于发布器和协议版本。应用不应手工设置与实际内容不一致的 Content-Length;长度错误会造成服务器等待、截断或协议解析失败。


HTTP 状态码不是 Java 异常

下面的代码即使收到 HTTP 500,也可能正常返回:

HttpResponse<String> response =
        client.send(request, HttpResponse.BodyHandlers.ofString());

if (response.statusCode() >= 200 && response.statusCode() < 300) {
    System.out.println(response.body());
} else {
    System.err.println("Remote status = " + response.statusCode());
}

HTTP 状态码属于应用层响应。只要 HTTP 响应成功到达,send 就有可能返回 HttpResponse,无论状态码是 200、404、429 还是 503。

以下情况才通常表现为 Java 异常:

  • URI 格式非法;
  • DNS、TCP 或 TLS 建立失败;
  • 连接或响应等待超时;
  • 响应体读取失败;
  • 当前线程被中断;
  • 请求构造参数不合法;
  • 异步任务被取消或以异常完成。

因此,错误处理至少要分成两层:

try {
    HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() == 404) {
        // 资源不存在:通常不是网络重试问题
    } else if (response.statusCode() == 429 ||
               response.statusCode() >= 500) {
        // 可能具有暂时性,但仍需按方法和业务语义判断
    }
} catch (java.net.http.HttpTimeoutException e) {
    // 等待响应超时
} catch (javax.net.ssl.SSLHandshakeException e) {
    // TLS 身份验证或握手失败
} catch (java.io.IOException e) {
    // 其他 I/O 或连接错误
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    // 保留中断状态,不应静默吞掉
}

HttpTimeoutException 是 I/O 异常体系中的超时异常。TLS 握手失败通常会以 SSLHandshakeException 或其相关异常表现,但实际异常链可能包含更底层原因,应记录 getCause()


HTTP/1.1、HTTP/2 和连接复用

HTTP/1.1 通常在一个 TCP 连接上按顺序传输请求和响应。响应体没有被完整消费或关闭时,客户端往往无法安全复用该连接。

HTTP/2 在一个 TLS 连接上使用多个逻辑流(stream)并发传输请求和响应。这里的“流”是 HTTP/2 的协议概念,不等同于 Java InputStream,但两者都表示分段到达的数据。

Java 客户端可以偏好 HTTP/2:

HttpClient client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)
        .build();

HTTPS 下 HTTP/2 通常通过 TLS 的 ALPN 协商。客户端提出支持的协议,服务器选择双方都支持的版本。如果服务器不支持 HTTP/2,客户端可能回退到 HTTP/1.1;因此设置偏好版本不等于强制服务器使用该版本。

连接复用有几个重要条件:

  1. 目标主机、端口和协议必须匹配;
  2. 连接仍然可用;
  3. 服务器允许保持连接;
  4. 前一个响应的消息体已被完整消费或正确关闭;
  5. TLS 会话和协议状态仍然有效。

连接复用不是业务层保证。服务器可能主动关闭连接,负载均衡器也可能在空闲一段时间后丢弃连接。客户端应把“复用连接”视为优化,而不是正确性前提。


TLS:加密不等于身份认证

HTTPS 中的 TLS 解决三个不同问题:

  • 机密性:旁路观察者不能直接读取通信内容;
  • 完整性:通信内容被修改时能够检测;
  • 身份认证:客户端验证服务器是否拥有与目标主机匹配的可信证书。

第三点尤其重要。仅仅“启用 HTTPS”并不意味着已经正确验证了服务器身份。默认 HttpClient 通常使用默认的 SSLContext 和信任库。信任库中的根证书用于构建证书链,主机名校验用于确认目标主机名与证书中的名称匹配。

不要使用以下方式“解决”证书问题:

// 不应在生产环境中关闭证书或主机名校验

Java 标准 API 可以配置自定义 SSLContext,例如使用特定信任库:

KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(Path.of("company-truststore.p12"))) {
    trustStore.load(in, password);
}

TrustManagerFactory tmf =
        TrustManagerFactory.getInstance(
                TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);

HttpClient client = HttpClient.newBuilder()
        .sslContext(sslContext)
        .build();

这段代码改变了信任根集合。它适用于企业内部 CA 或受控环境,但有明显风险:如果自定义信任库遗漏公共 CA,外部 HTTPS 请求可能失败;如果把任意证书加入信任库,验证边界就被扩大。

双向 TLS(mTLS)还需要客户端证书和私钥,此时 SSLContext.init 的第一个参数应提供 KeyManager。私钥口令、证书文件和信任库必须按密钥管理要求保护,不能放进源代码或普通日志。

TLS 握手失败的诊断应区分:

  • PKIX path building failed:通常是信任链无法建立;
  • No subject alternative DNS name:证书名称与目标主机不匹配;
  • handshake_failure:协议、密码套件、客户端证书或服务器策略不兼容;
  • certificate_unknown:一方不接受对方证书。

可以通过 Java TLS 调试参数观察握手过程:

java -Djavax.net.debug=ssl,handshake YourMainClass

该输出可能包含证书和协商细节,不应在生产环境长期启用,也不应将完整日志直接发送到公共日志系统。


超时不是一个数字,而是一组边界

一次请求可以拆成多个耗时阶段:

Ttotal=Tdns+Tconnect+Ttls+Trequest+Theaders+TbodyT_{\text{total}} = T_{\text{dns}} + T_{\text{connect}} + T_{\text{tls}} + T_{\text{request}} + T_{\text{headers}} + T_{\text{body}}

其中:

  • TdnsT_{\text{dns}}:DNS 解析耗时;
  • TconnectT_{\text{connect}}:TCP 连接建立耗时;
  • TtlsT_{\text{tls}}:TLS 握手耗时;
  • TrequestT_{\text{request}}:上传请求体耗时;
  • TheadersT_{\text{headers}}:等待响应头耗时;
  • TbodyT_{\text{body}}:读取响应体耗时。

Java 的两个常用超时配置分别作用在不同位置:

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2))
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(uri)
        .timeout(Duration.ofSeconds(5))
        .GET()
        .build();

connectTimeout 针对连接建立阶段。HttpRequest.timeout 是单个请求等待 HTTP 响应的超时配置,未设置时可以使用客户端默认值。它不应被理解为自动覆盖 DNS、请求体上传和所有响应体读取时间的统一总预算。

尤其要注意响应体处理方式。

缓冲型响应体

HttpResponse<String> response =
        client.send(request, HttpResponse.BodyHandlers.ofString());

ofString() 会持续收集响应体,直到响应体完成后 send 才返回。因此这里的调用时间通常包括响应体接收过程。

但这并不意味着所有底层等待都严格受同一个计时器限制。连接、响应头和读取过程仍受实现、协议和请求处理路径影响。

流式响应体

HttpResponse<InputStream> response =
        client.send(request, HttpResponse.BodyHandlers.ofInputStream());

try (InputStream in = response.body()) {
    byte[] buffer = new byte[8192];
    int n;
    while ((n = in.read(buffer)) != -1) {
        // 处理当前分块
    }
}

ofInputStream()send 可能在响应头到达后就返回,后续网络等待发生在 in.read() 中。于是:

client.send(...)

返回得很快,并不表示整个响应已经接收完成;真正的长时间阻塞可能发生在读取流的代码里。

如果业务需要“整个操作不得超过一个截止时间”,应显式建立 deadline,而不是只配置 HttpRequest.timeout。异步情况下可以使用:

CompletableFuture<HttpResponse<byte[]>> future =
        client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
              .orTimeout(10, TimeUnit.SECONDS);

这里的 orTimeoutCompletableFuture 层的超时。对于 ofByteArray(),future 通常在整个响应体收完后完成,因此它可以限制“等待完整响应”的时间。对于 ofInputStream(),future 可能只代表响应头和流对象已经准备好,不能自动限制之后的 read()

同步读取流时,可以用 deadline 包围整个业务操作:

long deadline = System.nanoTime()
        + TimeUnit.SECONDS.toNanos(10);

try (InputStream in = response.body()) {
    while (true) {
        long remaining = deadline - System.nanoTime();
        if (remaining <= 0) {
            throw new TimeoutException("response body deadline exceeded");
        }

        // read() 本身不接受 timeout 参数;
        // 这里还需要底层 socket 超时、异步读取或可取消的执行模型配合。
        int value = in.read();
        if (value == -1) {
            break;
        }
    }
}

这个例子说明了一个边界:计算 deadline 并不会让普通 InputStream.read() 自动具备超时能力。若 read() 已经阻塞,当前线程仍可能无法在 deadline 到达时立即返回。要严格限制流式读取,通常需要使用可配置读取超时的底层网络模型、异步管道、独立任务取消,或让服务端提供可控的响应行为。


响应体就是资源:InputStream、文件和发布器

BodyHandler<T> 决定响应体如何转换为 T。常见处理器有:

HttpResponse<String> text =
        client.send(request, HttpResponse.BodyHandlers.ofString());

HttpResponse<byte[]> bytes =
        client.send(request, HttpResponse.BodyHandlers.ofByteArray());

HttpResponse<Path> file =
        client.send(request,
                HttpResponse.BodyHandlers.ofFile(Path.of("result.bin")));

HttpResponse<InputStream> stream =
        client.send(request,
                HttpResponse.BodyHandlers.ofInputStream());

它们的内存和生命周期含义不同:

  • ofString():把全部内容放入内存,并按指定或推断的字符集解码;
  • ofByteArray():把全部内容放入一个字节数组;
  • ofFile():将内容写入文件,适合较大响应,但要处理临时文件、覆盖和磁盘空间;
  • ofInputStream():由应用逐步消费,适合流式处理,但必须关闭。

一个典型的文件下载应先写临时文件,成功后再原子替换目标文件:

Path target = Path.of("archive.zip");
Path temp = Files.createTempFile(target.getParent(), "archive-", ".tmp");

HttpRequest request = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofSeconds(20))
        .GET()
        .build();

try {
    HttpResponse<Path> response =
            client.send(request,
                    HttpResponse.BodyHandlers.ofFile(temp));

    if (response.statusCode() / 100 != 2) {
        Files.deleteIfExists(temp);
        throw new IOException("download failed: " + response.statusCode());
    }

    Files.move(temp, target,
            StandardCopyOption.REPLACE_EXISTING,
            StandardCopyOption.ATOMIC_MOVE);
} catch (Throwable failure) {
    Files.deleteIfExists(temp);
    throw failure;
}

这里有三个关键因果关系:

  1. 不能先覆盖正式文件再开始下载,否则中途断开会留下损坏文件;
  2. 只有状态码和完整写入都成功,临时文件才可成为正式文件;
  3. ATOMIC_MOVE 是否可用取决于文件系统,失败时不能假设所有存储都提供原子替换语义。

对于 InputStream,即使业务只关心状态码,也必须处理响应体:

HttpResponse<InputStream> response =
        client.send(request, HttpResponse.BodyHandlers.ofInputStream());

try (InputStream body = response.body()) {
    if (response.statusCode() / 100 == 2) {
        consume(body);
    } else {
        // 至少读取或关闭错误响应体
        body.transferTo(OutputStream.nullOutputStream());
    }
}

不关闭流可能导致连接无法复用、资源延迟回收,甚至在高并发下耗尽连接或文件描述符。


异步 HTTP 与背压

异步请求:

CompletableFuture<HttpResponse<String>> future =
        client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString());

future.thenApply(HttpResponse::statusCode)
      .thenAccept(System.out::println)
      .exceptionally(error -> {
          error.printStackTrace();
          return null;
      });

sendAsync 返回 CompletableFuture。异常通常会包装在 CompletionException 中,因此诊断时应查看 getCause()

future.whenComplete((response, error) -> {
    if (error != null) {
        Throwable cause = error instanceof CompletionException
                && error.getCause() != null
                ? error.getCause()
                : error;
        cause.printStackTrace();
        return;
    }

    System.out.println(response.statusCode());
});

异步不等于没有阻塞。sendAsync 允许调用线程不等待结果,但响应体处理、回调代码和下游数据库操作仍可能消耗线程。如果在回调中执行长时间阻塞操作,仍然会造成线程资源压力。

流式响应可以使用 BodyHandlers.ofPublisher(),获得一个发布响应数据的 Flow.Publisher<List<ByteBuffer>>。它采用 Reactive Streams 风格的订阅和需求量控制:

HttpResponse<Flow.Publisher<List<ByteBuffer>>> response =
        client.send(request,
                HttpResponse.BodyHandlers.ofPublisher());

订阅者通过 Subscription.request(n) 表示自己愿意接收多少个数据项。这个机制是背压:下游处理速度不足时,不应无限制地继续拉取数据。

一个简化的订阅者如下:

response.body().subscribe(new Flow.Subscriber<>() {
    private Flow.Subscription subscription;

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription;
        subscription.request(1);
    }

    @Override
    public void onNext(List<ByteBuffer> buffers) {
        for (ByteBuffer buffer : buffers) {
            // 处理 buffer 中 remaining() 范围内的数据
        }
        subscription.request(1);
    }

    @Override
    public void onError(Throwable throwable) {
        throwable.printStackTrace();
    }

    @Override
    public void onComplete() {
        System.out.println("body complete");
    }
});

ByteBuffer 的有效数据位于 position()limit() 之间,不能假定整个底层数组都有效,也不能随意调用 array():某些 buffer 可能是只读的或没有可访问数组的直接缓冲区。

流式处理的正确性依赖于完整消费、关闭或取消订阅。只读取前几 KB 就丢弃响应,可能使底层连接进入不可复用状态,客户端最终关闭它而不是复用它。


请求体流和可重复性

重试请求时,响应体不是唯一要考虑的流,请求体也可能不可重复。

以下请求体通常容易重复构造:

byte[] data = json.getBytes(StandardCharsets.UTF_8);

HttpRequest request = HttpRequest.newBuilder(uri)
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofByteArray(data))
        .build();

因为每次发送都能从内存数据重新发布。

而使用文件、输入流或自定义发布器时,应确认发布器是否能在重试时重新提供完整内容。一个依赖一次性 InputStream 的发布器,如果第一次发送已经读到末尾,第二次重试可能得到空请求体或半截请求体。

对于重试,比较安全的抽象是“每次尝试重新创建请求体”:

Supplier<HttpRequest> requestFactory = () ->
        HttpRequest.newBuilder(uri)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofByteArray(data))
                .build();

这样请求对象、请求体发布器和尝试次数之间的关系是显式的。


错误恢复:先区分失败点,再决定动作

错误恢复不能只看异常类型,还要看请求是否可能已经到达服务器。

例如客户端写请求体时连接断开:

  • 服务器可能尚未收到请求;
  • 服务器可能已经执行了业务操作;
  • 客户端可能只是在等待响应时失去连接。

因此,“客户端收到 I/O 异常”不等于“服务器没有执行”。

可恢复和不可恢复的典型区别

故障 可能原因 默认动作
DNS 解析失败 配置错误、DNS 故障 通常不立即重试,先诊断
TCP 连接超时 网络拥塞、服务不可达 幂等请求可有限重试
TLS 证书失败 信任或主机名错误 不重试,修复配置
HTTP 400 请求参数错误 不重试
HTTP 401/403 认证或授权失败 刷新凭证或修复权限
HTTP 404 资源不存在 通常不重试
HTTP 429 限流 尊重 Retry-After
HTTP 500/502/503/504 服务端或网关暂时失败 幂等请求可有限重试
响应体读取中断 网络断开、服务器关闭 只有确认可重试且可接受重复时才重试

HTTP 方法语义也很重要:

  • GETHEADOPTIONS 通常设计为幂等;
  • PUTDELETE 在 HTTP 语义上通常具有幂等设计;
  • POST 默认不应假设幂等。

“幂等”不是“绝对不会产生副作用”,而是重复执行相同请求的最终效果应等价。例如删除一个已经删除的资源,结果可能仍然符合幂等语义;但重复创建订单通常不是。

对于非幂等操作,应使用业务幂等键:

HttpRequest request = HttpRequest.newBuilder(uri)
        .header("Idempotency-Key", requestId)
        .POST(HttpRequest.BodyPublishers.ofByteArray(data))
        .build();

是否支持 Idempotency-Key 取决于服务器协议。客户端单方面添加该头部并不会自动产生幂等效果。


退避重试的推导与边界

设第 kk 次重试前的基础等待时间为:

dk=min(dmax,d0×2k)d_k = \min(d_{\max}, d_0 \times 2^k)

其中:

  • d0d_0 是初始延迟;
  • k=0k=0 表示第一次重试;
  • dmaxd_{\max} 是最大延迟;
  • 实际等待时间通常还应加入随机抖动。

全抖动(full jitter)可以定义为:

wkU(0,dk)w_k \sim U(0, d_k)

它使大量客户端不会在同一时刻再次冲击服务端。

static Duration backoff(int retryIndex, Duration initial, Duration max) {
    long initialMillis = initial.toMillis();
    long maxMillis = max.toMillis();

    long exponential;
    if (retryIndex >= 62 ||
            initialMillis > maxMillis / (1L << retryIndex)) {
        exponential = maxMillis;
    } else {
        exponential = Math.min(
                maxMillis,
                initialMillis * (1L << retryIndex));
    }

    long waitMillis = ThreadLocalRandom.current()
            .nextLong(exponential + 1);

    return Duration.ofMillis(waitMillis);
}

生产实现还应限制:

  • 最大尝试次数;
  • 总 deadline;
  • 可重试的状态码和异常;
  • 单次请求是否已经消耗了部分请求体;
  • 是否应优先遵从服务器的 Retry-After

重试必须受总时间预算约束。若每次尝试最多等待 5 秒,退避再等待 10 秒,而调用方总共只允许 8 秒,那么重试策略本身就违反了上游 SLA。


一个带分类处理的同步请求

下面的示例展示“HTTP 状态”和“Java 异常”分开处理:

static HttpResponse<String> getWithClassification(
        HttpClient client,
        URI uri) throws IOException, InterruptedException {

    HttpRequest request = HttpRequest.newBuilder(uri)
            .timeout(Duration.ofSeconds(5))
            .header("Accept", "application/json")
            .GET()
            .build();

    HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

    int status = response.statusCode();

    if (status >= 200 && status < 300) {
        return response;
    }

    if (status == 429 || status == 502 ||
            status == 503 || status == 504) {
        throw new IOException("temporary HTTP failure: " + status);
    }

    if (status >= 400 && status < 500) {
        throw new IllegalArgumentException(
                "client-side HTTP failure: " + status);
    }

    throw new IOException("unexpected HTTP status: " + status);
}

这个示例把 5xx 包装成 IOException,是为了让上层重试器统一处理;但它不是 Java 或 HTTP 的强制规则。实际系统也可以定义专门的 RetryableHttpException,携带状态码、响应头和错误体摘要。

不能把所有 4xx 都当作永久失败。例如 408、409、429 的恢复方式可能不同:

  • 408 可能是请求超时;
  • 409 可能需要重新读取资源后解决冲突;
  • 429 需要限速或等待;
  • 401 可能需要刷新凭证;
  • 403 通常不是刷新凭证就能解决。

恢复逻辑应由协议和业务语义共同决定。


CompletableFuture 的错误传播与取消

异步链中的异常不会自动抛到调用线程,而是沿着 future 传播:

CompletableFuture<HttpResponse<String>> future =
        client.sendAsync(request,
                HttpResponse.BodyHandlers.ofString());

CompletableFuture<String> bodyFuture = future.thenCompose(response -> {
    if (response.statusCode() / 100 != 2) {
        return CompletableFuture.failedFuture(
                new IOException("status=" + response.statusCode()));
    }
    return CompletableFuture.completedFuture(response.body());
});

bodyFuture.exceptionally(error -> {
    Throwable cause = unwrap(error);
    System.err.println("failed: " + cause);
    return null;
});

辅助方法:

static Throwable unwrap(Throwable error) {
    while ((error instanceof CompletionException ||
            error instanceof ExecutionException) &&
            error.getCause() != null) {
        error = error.getCause();
    }
    return error;
}

future.cancel(true) 表示取消 future,但取消传播到网络操作的及时性和具体效果仍受实现和当前阶段影响。取消之后还应确保响应体、下游资源和业务事务不会继续被错误使用。

如果业务要求超时后真正停止昂贵的下游处理,仅调用 orTimeout 还不够:它会让 future 以超时异常完成,底层操作是否已经停止、响应体是否仍在到达,需要结合取消、关闭流和执行器生命周期验证。


连接故障的诊断路径

遇到 HTTP 请求失败时,建议按照协议层逐级定位。

1. URI 和配置

检查:

scheme: https
host: api.example.com
port: 443
path: /v1/items

常见错误包括把服务名写成容器内部名称、端口写错、路径编码错误,以及把用户输入直接拼接成 URI。

2. DNS

确认解析结果是否正确:

getent hosts api.example.com

或:

nslookup api.example.com

DNS 返回地址只说明名称解析成功,不说明该地址从当前网络可达。

3. TCP

检查端口连通性:

nc -vz api.example.com 443

连接失败可能来自防火墙、安全组、代理、路由或服务器监听配置。

4. TLS

查看证书链和主机名:

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com

-servername 很重要,因为多个 HTTPS 站点可能依赖 SNI 选择证书。

5. HTTP

使用 curl 验证服务器实际返回:

curl -v --connect-timeout 3 --max-time 10 \
  https://api.example.com/v1/items

需要注意,curl 与 Java HttpClient 的代理、信任库、HTTP/2 协商和请求头可能不同,因此它只能帮助隔离问题,不能证明两者配置完全相同。

6. Java 运行时

记录:

  • 请求 URI 的主机和端口;
  • HTTP 版本;
  • 状态码;
  • 异常类型和完整 cause 链;
  • 连接和响应耗时;
  • 请求是否已经发送;
  • 响应体是否已经消费或关闭。

不要把 Authorization、Cookie、完整请求体和可能含个人数据的响应体写入普通日志。


重定向、代理和 SSRF 边界

重定向不是天然安全或天然错误。启用自动重定向:

HttpClient client = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

应用仍需考虑:

  • 重定向是否跨越主机;
  • 是否从 HTTPS 降级到 HTTP;
  • Authorization 等敏感头部是否应继续发送;
  • 重定向次数是否过多;
  • 最终 URI 是否仍在允许范围内。

如果 URI 来自用户输入,HTTP Client 还可能成为 SSRF(服务端请求伪造)入口。不能只验证字符串前缀,例如“以 https://trusted.example 开头”;应解析 URI,限制 scheme、端口、解析后的 IP 范围和重定向目标,并防止访问本机、云元数据地址和内部管理网段。

代理可通过 ProxySelector 配置。代理会改变连接路径:

Java 应用 -> 代理 -> 目标服务器

对于 HTTPS,常见方式是先与代理建立连接,再通过 CONNECT 隧道连接目标服务器;TLS 握手通常发生在隧道内。于是“目标地址可达”不等于“代理路径可达”。


字符集、长度和响应体上限

ofString() 涉及字符集解码。若协议响应头明确指定字符集,应按协议处理;没有可靠声明时,必须确认服务端约定,不能因为 HTTP 文本通常使用 UTF-8 就无条件假定所有数据都是 UTF-8。

对于不可信服务端,不应无限制使用 ofByteArray()ofString()。如果响应体大小为 NN,内存占用至少与 NN 同阶,还可能由于字符解码、字符串内部存储和对象结构产生额外开销。

流式读取可以实施大小上限:

static void copyWithLimit(
        InputStream in,
        OutputStream out,
        long maxBytes) throws IOException {

    byte[] buffer = new byte[8192];
    long total = 0;
    int n;

    while ((n = in.read(buffer)) != -1) {
        if (n > maxBytes - total) {
            throw new IOException("response body too large");
        }

        out.write(buffer, 0, n);
        total += n;
    }
}

注意:HTTP 的 Content-Length 只是声明,不应作为唯一安全边界。分块传输、压缩、错误响应和恶意服务端都可能使声明与实际处理成本不一致。真正的限制必须放在实际读取路径上。


常见误解与反例

误解一:HTTP 500 会自动抛异常

反例:

HttpResponse<String> r =
        client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println(r.statusCode()); // 可能打印 500

HTTP 响应成功到达,所以 Java API 没有理由把 500 自动视为传输失败。是否把它当作业务异常,由应用判断。

误解二:请求超时就是整个函数不会超过该时间

反例:

HttpRequest request = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofSeconds(2))
        .GET()
        .build();

HttpResponse<InputStream> r =
        client.send(request, HttpResponse.BodyHandlers.ofInputStream());

Thread.sleep(10_000);
r.body().read(); // 流式读取有自己的生命周期

请求 future 或 send 返回,不表示后续流读取一定受同一边界限制。

误解三:关闭证书校验即可解决 TLS 问题

这会把“暂时连不上”变成“可能连接到错误服务器并泄露数据”。正确做法是修复信任链、主机名、证书有效期、SNI 或 mTLS 配置。

误解四:网络异常重试一定安全

客户端可能在服务器执行完成后才断线。如果无幂等设计,重试可能创建重复订单、重复扣款或重复发送消息。

误解五:读一点响应体再丢弃没有影响

未消费或未关闭的响应体可能阻止连接复用。对于高并发客户端,这会表现为连接数增长、请求排队、超时和资源耗尽,而不是立即出现明显错误。


与 Java I/O、NIO 和 Web 框架的边界

InputStream 是阻塞式字节读取抽象;ByteBuffer 是带 position、limit、capacity 状态的缓冲区;Channel 通常提供比流更接近底层 I/O 的读写模型。HTTP Client 的响应体处理器把网络字节转换成这些抽象之一:

网络连接
  -> HTTP 解码
  -> BodyHandler
  -> String / byte[] / Path / InputStream / Publisher<ByteBuffer>

边界在于:

  • Stringbyte[] 适合小型、完整响应;
  • Path 适合直接落盘;
  • InputStream 适合传统阻塞式管道;
  • Publisher<ByteBuffer> 适合需要背压的异步流式管道。

Spring MVC 通常以阻塞式 Servlet 请求链为基础,调用阻塞的 InputStreamHttpClient.send 时,应考虑工作线程占用。Spring WebFlux 基于响应式流,适合把异步 HTTP 响应接入带背压的链路,但“使用 WebFlux”并不会自动使阻塞 I/O 变成非阻塞;阻塞调用仍需隔离到合适的线程池。

因此,选择 ofString()ofInputStream()ofPublisher(),不只是 API 偏好,而是对内存、线程、背压、取消和下游处理能力的选择。


生产环境中应验证的闭环

一个 HTTP Client 组件是否可靠,不应只用 200 响应测试。至少需要验证:

  1. DNS 不可用时,异常是否可诊断;
  2. 端口拒绝和连接超时是否区分;
  3. 证书过期、错误主机名和不受信任 CA 是否失败;
  4. 服务器返回 400、401、404、429、500、503 时是否按状态处理;
  5. 响应体很大时是否触发大小上限;
  6. 响应体读取中途断开时是否关闭资源;
  7. 请求体重试时是否能重复发布;
  8. 超时后下游任务是否仍可能继续运行;
  9. 重定向是否越过安全边界;
  10. 高并发下连接是否复用、排队和失败率是否符合预期。

可以使用本地延迟服务复现响应超时:

python3 -m http.server 8080

它可以验证基本 HTTP 访问,但不会模拟慢响应体、TLS 或 503。要测试完整故障路径,应使用能够控制延迟、分块发送、断开连接和状态码的测试服务,并通过日志确认失败发生在 DNS、连接、TLS、响应头还是响应体阶段。

HTTP Client 的核心不是“把请求发出去”,而是管理一次跨越多个协议层的生命周期:连接可能复用,也可能失效;TLS 可能验证失败;超时可能只覆盖响应头;响应体可能在返回 HttpResponse 后仍持续到达;网络异常可能发生在服务器执行之后。只有把这些状态和边界显式建模,错误恢复才不会把暂时性故障变成数据重复、资源泄漏或安全问题。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。