登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java HTTP Client 实现带取消与重试的异步请求

来源:17golang原创

时间:2026-10-08 13:12:57 212浏览 收藏

HttpClient.sendAsync 能立即返回 CompletableFuture>,但“异步”并不自动包含业务可用的重试和取消策略。稳妥的封装应同时持有一个对外结果 Future 与一个“当前任务”引用:当前任务可能是正在进行的 HTTP 请求,也可能是退避等待。调用方取消外层 Future 时,封装取消当前任务,并在所有回调入口检查外层结果是否已经结束,从而阻止后续重试继续启动。

官方 API:https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpClient.html

实现边界
  • 示例只自动重试幂等 GET,请求次数包含第一次尝试。
  • 重试 IOException 与 429、502、503、504;取消和其他 4xx 直接停止。
  • cancel(true) 只会尽力取消 HTTP 交换,不能保证服务端尚未收到请求。

特性解决什么:把异步、取消和重试放进一个边界

常见写法是在 sendAsync 后接一串 thenCompose。只处理一次请求时很简洁,一旦加入退避重试,外层 Future、当前 HTTP 交换和延迟任务就容易失去联系。我遇到的典型症状是:调用方已经取消 Future,某个延迟回调仍在稍后发起下一次请求。

解决思路不是创建更多 Future,而是明确三类对象:HttpClient 和 HttpRequest 属于请求对象;当前 HTTP Future 或延迟 Future 属于可取消任务;最终返回给调用方的是单独的外层 Future。每次替换当前任务后,都再次检查外层是否已经取消,以覆盖并发竞态。

Java HttpClient 异步请求、当前任务、延迟任务与外层 Future 的静态边界关系图
图1:异步请求与取消控制的静态边界。外层 Future 统一关联当前 HTTP 任务和退避等待任务,取消时只操作当前持有的任务。

支持范围:只重试幂等 GET 与暂时性失败

重试之前先确定“哪些请求重复执行不会制造额外副作用”。GET 通常按幂等语义使用,适合作为演示;POST、PATCH 等写操作不能因为网络异常就默认重发,因为服务端可能已经完成处理,只是客户端没有收到响应。若业务确实需要重试写请求,应同时设计幂等键、服务端去重和结果查询机制。

状态码也不应全部重试。401、403、404 等通常需要修正身份、权限或资源,而不是等待后再发一次。示例只把 429、502、503、504 视为暂时性候选,并把 I/O 异常纳入重试。生产环境还应解析合法的 Retry-After,并用随机抖动减少大量客户端同时重试。

Java 异步 HTTP 请求可重试条件、停止条件和策略约束静态关系图
图2:重试策略的静态边界。只有幂等 GET 的暂时性失败进入退避,取消、其他 4xx 和次数耗尽都直接形成最终结果。

最小示例:实现可取消的 sendWithRetry

下面的类复用一个不可变的 HttpClient,用 AtomicReference> 保存当前请求或等待任务。maxAttempts 包含第一次请求;退避从 200 毫秒开始翻倍,并限制在 2 秒以内。

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

public final class RetryingHttpClient {
    private static final Set RETRYABLE_STATUS =
            Set.of(429, 502, 503, 504);

    private final HttpClient client;

    public RetryingHttpClient(HttpClient client) {
        this.client = client;
    }

    public CompletableFuture> sendGet(
            HttpRequest request, int maxAttempts) {
        // 示例只允许幂等 GET,避免把写操作意外执行多次。
        if (!"GET".equalsIgnoreCase(request.method())) {
            throw new IllegalArgumentException("只支持 GET 重试");
        }
        if (maxAttempts  10) {
            throw new IllegalArgumentException("maxAttempts 必须在 1 到 10 之间");
        }

        CompletableFuture> result =
                new CompletableFuture();
        AtomicReference> current = new AtomicReference();

        // 外层取消时,尽力取消当前 HTTP 交换或退避等待。
        result.whenComplete((response, error) -> {
            if (result.isCancelled()) {
                Future> task = current.get();
                if (task != null) {
                    task.cancel(true);
                }
            }
        });

        attempt(request, 1, maxAttempts, result, current);
        return result;
    }

    private void attempt(
            HttpRequest request,
            int attemptNo,
            int maxAttempts,
            CompletableFuture> result,
            AtomicReference> current) {
        if (result.isDone()) {
            return; // 取消或完成后不再发起新请求。
        }

        final CompletableFuture> call;
        try {
            call = client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
        } catch (RuntimeException error) {
            result.completeExceptionally(error);
            return;
        }
        installCurrent(current, call, result);

        call.whenComplete((response, error) -> {
            if (result.isDone()) {
                return; // 外层已经取消或被其他分支完成。
            }

            Throwable cause = unwrap(error);
            if (cause != null) {
                if (isRetryable(cause) && attemptNo > result,
            AtomicReference> current) {
        long delayMillis = Math.min(200L  delayTask = CompletableFuture.runAsync(
                () -> attempt(request, attemptNo + 1, maxAttempts, result, current),
                CompletableFuture.delayedExecutor(
                        delayMillis, TimeUnit.MILLISECONDS));
        installCurrent(current, delayTask, result);

        delayTask.exceptionally(error -> {
            if (!result.isDone()) {
                result.completeExceptionally(unwrap(error));
            }
            return null;
        });
    }

    private static void installCurrent(
            AtomicReference> current,
            Future> next,
            CompletableFuture> result) {
        current.set(next);
        // 覆盖“刚检查未取消,随后才安装任务”的竞态窗口。
        if (result.isCancelled()) {
            next.cancel(true);
        }
    }

    private static boolean isRetryable(Throwable error) {
        // HttpTimeoutException 也是 IOException,会进入有限重试。
        return error instanceof IOException
                && !(error instanceof CancellationException);
    }

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

CancellationException 本身不会进入重试。实际由外层取消触发时,回调首先看到 result.isDone(),直接停止;显式排除 CancellationException 则让异常分类更清楚。最后一次收到可重试状态码时,示例返回该响应而不是改造成异常,调用方仍能读取状态、响应头和服务端说明。

调用方式:设置连接超时和单次请求超时

connectTimeout 属于客户端配置,HttpRequest.Builder.timeout 属于单个请求。两者不能替代总重试预算,但能避免某一次尝试无限占用时间。HttpClient 构建后不可变,并会管理可复用连接池,因此通常应跨请求复用,而不是每次调用都新建一个客户端。

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

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

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/catalog"))
        .timeout(Duration.ofSeconds(5)) // 限制单次请求等待时间。
        .GET()
        .build();

RetryingHttpClient retrying = new RetryingHttpClient(client);
CompletableFuture> future =
        retrying.sendGet(request, 3); // 总尝试次数最多为三次。

future.whenComplete((response, error) -> {
    if (error != null) {
        // 统一记录最终异常,避免在每次重试中重复报警。
        System.err.println("请求失败: " + error.getMessage());
        return;
    }
    System.out.println("HTTP " + response.statusCode());
});

// 当上游任务取消时调用;封装会尽力取消当前请求或等待任务。
// future.cancel(true);

示例域名仅用于说明,不应直接投入业务。接入真实地址时还要设置必要的认证头、请求 ID 和可观测字段,并避免把令牌或完整响应体写入日志。

兼容处理:区分超时、取消与重试耗尽

HttpClient 自 Java 11 提供,CompletableFuture.delayedExecutor 在更早版本已经存在,因此上述核心模式可用于 Java 11 及以后版本。当前 Java 文档中的客户端关闭与等待终止 API 是后续版本增强;若项目需要同时支持较旧运行时,应复用长生命周期客户端,不要把新版本的客户端生命周期写法直接带回旧版本。

异常完成时,join() 常以 CompletionException 包装原始异常,所以封装内部先展开包装。取消应由调用方识别 CancellationException;请求超时通常表现为 HttpTimeoutException,它属于 IOException。示例允许它在次数范围内重试,但如果接口本身响应很慢,重复超时可能只会增加压力,应根据场景决定是否将其排除。

需要特别注意,官方文档把异步 Future 的取消描述为“尝试取消 HTTP 交换”。取消生效时机没有保证,请求可能已经发给服务端,底层资源也可能稍后才释放。这正是自动重试必须限制在幂等操作上的原因。

性能与安全注意:避免重试风暴和重复副作用

检查项本文做法生产增强
幂等性只允许 GET写请求使用幂等键与服务端去重
次数最多 1–10 次按接口 SLA 设置更小默认值
退避指数退避,最大 2 秒加入随机抖动并解析 Retry-After
超时连接与单次请求分别设置增加覆盖全部尝试的总预算
并发复用 HttpClient增加并发上限、舱壁或断路器
响应体ofString 缓冲字符串大响应使用流式处理并正确关闭

重试日志应在最终失败时统一记录,单次尝试只写低级别指标,否则一次用户请求会制造多条错误告警。对于 429 和 503,还应优先尊重服务端给出的等待建议。不要对证书错误、权限错误或参数错误进行盲目重试,也不要在日志中输出认证信息。

相关问题

为什么不直接使用 orTimeout 作为取消?

orTimeout 让 CompletableFuture 异常完成,但业务仍要明确是否取消底层 HTTP 交换。本文通过外层 cancel(true) 显式传播取消,并用请求 timeout 控制单次尝试。

最后一次返回 503 时为什么不是异常?

HTTP 响应本身已经成功到达客户端,状态码属于协议结果。保留最后响应便于调用方读取 Retry-After 和错误体;如果业务希望非 2xx 都异常,可在外层再映射。

能否把 BodyHandlers.ofString 换成文件或流?

可以,但流式或发布式响应体必须被读取完、关闭或取消,否则可能阻碍请求完成和客户端资源回收。重试前还要确认请求体是否可重复发布。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>