当前位置:首页 > 文章列表 > 文章 > java教程 > Java HTTP Client 实现带取消与重试的异步请求

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

来源:17golang原创 2026-10-08 13:12:57 0浏览 收藏

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删除
用类型约束实现数值聚合而不牺牲可读性用类型约束实现数值聚合而不牺牲可读性
上一篇
用类型约束实现数值聚合而不牺牲可读性
泛型函数何时比接口更合适,判断标准是数据还是行为
下一篇
泛型函数何时比接口更合适,判断标准是数据还是行为
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    375次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    446次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    455次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    399次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    226次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码