Java HTTP Client 实现带取消与重试的异步请求
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。每次替换当前任务后,都再次检查外层是否已经取消,以覆盖并发竞态。

支持范围:只重试幂等 GET 与暂时性失败
重试之前先确定“哪些请求重复执行不会制造额外副作用”。GET 通常按幂等语义使用,适合作为演示;POST、PATCH 等写操作不能因为网络异常就默认重发,因为服务端可能已经完成处理,只是客户端没有收到响应。若业务确实需要重试写请求,应同时设计幂等键、服务端去重和结果查询机制。
状态码也不应全部重试。401、403、404 等通常需要修正身份、权限或资源,而不是等待后再发一次。示例只把 429、502、503、504 视为暂时性候选,并把 I/O 异常纳入重试。生产环境还应解析合法的 Retry-After,并用随机抖动减少大量客户端同时重试。

最小示例:实现可取消的 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 换成文件或流?
可以,但流式或发布式响应体必须被读取完、关闭或取消,否则可能阻碍请求完成和客户端资源回收。重试前还要确认请求体是否可重复发布。
用类型约束实现数值聚合而不牺牲可读性
- 上一篇
- 用类型约束实现数值聚合而不牺牲可读性
- 下一篇
- 泛型函数何时比接口更合适,判断标准是数据还是行为
-
- 文章 · java教程 | 21小时前 | Java · JVM · JVM Native Memory Tracking NMT jcmd 原生内存 JVM内存排查
- JVM 原生内存上涨但堆稳定,怎样用 NMT 分类定位
- 414浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · 性能优化 · Stream · Java教程 · Java Stream Spliterator 并行流 parallelStream Stream副作用
- Stream 并行化前先判断什么:数据规模、拆分与副作用
- 405浏览 收藏
-
- 文章 · java教程 | 1天前 | Java教程 · sealed interface Java密封类 模式匹配switch 支付结果 穷尽检查
- 密封类建模支付结果:穷尽分支与扩展边界
- 270浏览 收藏
-
- 文章 · java教程 | 1天前 | 数据校验 · api设计 · Java教程 · 参数校验 Java record API DTO Jakarta Validation 紧凑规范构造器 跨字段校验
- Java Record 作为 API DTO 时,校验逻辑放在哪里
- 370浏览 收藏
-
- 文章 · java教程 | 1天前 | 线程池 · 异常处理 · 并发编程 · Java教程 · CompletableFuture · 异步任务 completablefuture allOf Handle 结果汇总 CompletionException
- CompletableFuture 组合独立任务:allOf 结果汇总与失败归属
- 482浏览 收藏
-
- 文章 · java教程 | 1天前 |
- StructuredTaskScope 如何表达并发任务的共同生命周期
- 425浏览 收藏
-
- 文章 · java教程 | 1天前 | 并发编程 · Java教程 · java arena MemorySegment WrongThreadException FFM API
- Java MemorySegment 怎么限制跨线程访问范围
- 132浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · Java 24 Java Class-File API CodeTransform ClassTransform CodeAttribute
- Java Class-File API 怎么转换方法代码属性
- 199浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · Stream · java Stream Gatherer Integrator.Greedy
- Java Gatherer Integrator.Greedy 什么时候可以声明贪婪处理
- 112浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 375次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 446次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 455次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 399次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 226次使用
-
- Go Java 算法之字符串解码示例详解
- 2023-01-07 479浏览
-
- Go Java算法之单词搜索示例详解
- 2022-12-30 337浏览
-
- Gojava算法之括号生成示例详解
- 2023-02-22 128浏览
-
- GoJava算法之累加数示例详解
- 2023-01-07 149浏览
-
- GoJava算法最大单词长度乘积示例详解
- 2023-01-12 202浏览

