HttpAsyncClient重试机制:5xx可重试、4xx不可重试的判定与实战
2026/9/13 6:19:30 网站建设 项目流程

先说明一个很多人容易搞混的点:HttpAsyncClient里的“可重试异常”和 HTTP 状态码(5xx/4xx)并没有直接画等号。5xx/4xx是服务端返回的响应状态行,只有在服务端已经成功收到请求并给出响应之后才会出现;而HttpAsyncClient的重试处理器判断的是“请求是否成功发送并完成响应”这个过程中抛出的IOException。两者一个在网络层,一个在业务层,很多人把这两件事混在一起写重试逻辑,结果要么该重试的没重试,要么同一个请求重复提交了好几次。

这篇文章我直接用一个生产环境的真实视角,把异常分类、重试判定链路、代码落地、异步场景下的特殊坑位全部串一遍,尤其会讲清楚为什么 5xx 应该重试、4xx 通常不应该重试,以及在异步调用中这个判断到底放在哪一层做才有意义。适合正在用 Apache HttpAsyncClient 4.1.x 做网关、数据采集、服务间调用的同学。

1. 为什么“重试”不能拍脑袋:两个层面要分开看

1.1 异常层面与响应码层面是两回事

HttpAsyncClient最核心的特点在于它是 NIO 模型,请求发出后不阻塞等待响应,而是通过Future或者回调来获取最终结果。在这个模型里,一次调用从发起到返回结果,中间经历了好几个阶段:

  • 从连接池获取连接
  • 发起 TCP 连接(或者从连接池直接拿已建立的连接)
  • 发送请求头、请求体
  • 等待服务端返回响应头
  • 读取响应体

每一个阶段都可能出问题,但问题分两类。第一类是网络层的异常,比如连接超时、连接被重置、SSL 握手失败、Socket 超时,这类异常以IOException的形式冒出来。第二类是服务端正常返回了 HTTP 响应,但是响应码是 400、404、500,这属于“请求已经打完一个完整来回”的正常结果。

有人可能会问:那服务端返回 500 的时候,HttpAsyncClient为什么不直接抛异常?因为从 HTTP 协议的角度来说,只要客户端收到了响应行和响应头,这次交互在传输层就是成功的。500 是“本次请求执行完成之后,服务端应用逻辑处理失败”的语义,不应该由 HTTP 客户端强行翻译成异常。这个设计非常关键——如果你把 5xx 当成异常来捕获,那你的代码里处理重试的位置就放错了。

1.2 HTTP 状态码语义决定了“不可重试”的边界

为什么 4xx 通常不可重试?因为 4xx 的语义是“客户端请求本身有问题”。比如:

  • 400 Bad Request:请求参数格式不对,重试一百次还是格式不对。
  • 401 Unauthorized:没有认证或者认证过期,重试前需要先刷新 token。
  • 403 Forbidden:没有权限,重试无效。
  • 404 Not Found:资源路径不对,重试不可能把不存在的资源变成存在。
  • 409 Conflict:资源状态冲突,重试大概率继续冲突。

这些错误的重试只会带来两个后果:一是白白消耗连接池资源,二是可能因为请求被重复提交而对服务端造成额外压力,比如日志里一堆无意义的错误记录。

但是这里有一个非常容易被人忽略的例外:429 Too Many Requests。虽然 429 是 4xx,但它的语义不是“请求有问题”,而是“你被限流了”。这种场景下服务端通常会在响应头里带上Retry-After,告诉客户端多少秒后再试。所以严格来说,429 属于“可以重试、但要按服务端指定节奏重试”的特殊响应码。我之前在一个对接第三方开放平台的网关服务里,就在重试策略里单独对 429 开了白名单,并且强制读Retry-After,效果比固定间隔重试好得多。

反过来看 5xx。500、502、503、504 这些状态码背后往往意味着服务端在某个瞬间出了临时性问题——数据库连接池满了、上游服务超时、节点正在重启、GC 停顿。这类问题有比较高的概率在一两秒内恢复,所以重试是合理的。但必须控制重试次数和退避间隔,否则一个网关节点抖一下,你这边所有调用方同时重试,可能直接把服务端打挂,这叫“重试风暴”。

如果你要一句话总结:处理重试逻辑前,先想清楚你要处理的这一层到底是“网络交互失败”还是“业务语义失败”。网络交互失败由HttpAsyncClient的重试处理器负责,业务语义失败(5xx)由调用方根据自己的业务重试策略处理。

2. HttpAsyncClient 重试处理器的工作机制:从源码理解判定链路

2.1 HttpAsyncRequestRetryHandler 的触发时机与判定逻辑

HttpAsyncClient提供的重试扩展接口是HttpAsyncRequestRetryHandler,它长这样:

public interface HttpAsyncRequestRetryHandler { boolean retryRequest(IOException exception, int executionCount, HttpContext context); }

这个方法只有在请求执行过程中抛出IOException时才会被调用。也就是说,如果你不对响应状态码做任何检查,直接使用默认配置,那么 4xx/5xx 都不会触发这里的重试——因为根本没有异常。

触发场景是怎么发生的?我拆一下。当客户端在发送请求或者等待响应的过程中,如果IOReactor检测到连接异常(比如对端关闭)、读超时、写超时、连接池取不到连接等情况,会在DefaultHttpAsyncClientConnectionOperator或者MainClientExec里包一层HttpException/IOException抛出来。抛出后,异步执行器会调用retryRequest方法,询问“这次失败,要不要重新执行整个请求”。如果返回true,执行器会重新走一遍连接获取、请求发送、响应读取的完整链路;如果返回false,这个IOException会被直接原样抛给调用方。

默认配置里,HttpAsyncClients.custom()不会设置任何自定义重试处理器,但底层有一个DefaultHttpRequestRetryHandler,默认逻辑是:

  • 网络异常(IOException)默认最多重试 3 次;
  • 请求是幂等的(GET、HEAD、PUT、DELETE、OPTIONS、TRACE)才重试;
  • 某些特定的InterruptedIOExceptionUnknownHostExceptionConnectExceptionSSLException会直接放行不重试。

这个默认逻辑对很多场景是够用的。但问题在于它过于“一刀切”:它判断的是“这个请求方法是否幂等”,而不是“这次失败到底能不能靠重试来恢复”。比如一个 GET 请求因为服务端正常返回 500 而失败,默认处理器根本不会感知到,因为 500 并不是IOException;又比如一个 POST 请求因为连接池超时失败了,理论上网络恢复后再试大概率会成功,但默认处理器会因为 POST 非幂等直接放弃。

所以当你需要精细控制“哪些异常可以重试、哪些不可以”,就必须自定义HttpAsyncRequestRetryHandler

2.2 判定链路上的两个隐藏参数:executionCount 与 Context

retryRequest方法里有三个参数,很多人只盯着exception,忽略了另外两个,这其实会漏掉很多关键信息。

executionCount代表当前是第几次执行。注意这个值不是从 0 开始,而是从 1 开始。第一次执行失败后,executionCount已经是 2,此时你的重试逻辑要判断的是“还能不能再试第 2 次”。所以最常见的写法是:

if (executionCount > 3) { return false; }

意思是最多重试 3 次,加上第一次执行,总共最多发出 4 次请求。

contextHttpContext,它跟线程安全有关。在异步场景下,每次独立的请求会绑定一个独立的HttpClientContext,你可以在重试方法里通过context.getAttribute(HttpCoreContext.HTTP_REQUEST)拿到当前这次请求的原始对象,比如:

Object reqObj = context.getAttribute(HttpCoreContext.HTTP_REQUEST); if (reqObj instanceof HttpUriRequest) { HttpUriRequest req = (HttpUriRequest) reqObj; String method = req.getMethod(); // 根据 method 决定是否重试 }

这个能力的价值很大。因为不是所有IOException都适合重试,也不是所有请求方法都值得重试。举个例子:一个 GET 请求因为ConnectTimeoutException失败了,重试是合理的;但一个上传大文件流的 POST 请求因为SocketTimeoutException失败了,重试可能需要重新构造请求实体,甚至可能因为流已经被消费完而根本无法重发。通过context拿到原始的请求对象,你就能在处理器里做更精细的方法级判断。

还有一个隐藏信息:context里的HttpAsyncRequestProducer。如果请求体是InputStreamEntity,第一次发送失败后,流的指针已经走到底了,第二次发送时实体根本无法重新读取,这种情况下即便你强制重试,执行器也会在真正发送阶段抛NonRepeatableRequestException。这个坑我放在第 4 节详说,这里先记住一个原则:重试之前,先确认请求实体是否可以重复生成。

3. 实战:写一个生产级可重试异常分流器

3.1 需要处理的异常类型清单

自定义重试处理器之前,先把可能遇到的异常类型整理清楚。常见的有这几类:

异常类型含义是否可以重试理由
ConnectTimeoutException连接超时可以服务端可能临时过载,稍后连接可能成功
ConnectionPoolTimeoutException连接池取连接超时可以连接池可能被其他请求暂时占满,释放后即可获取
SocketTimeoutException读/写超时可以网络抖动或服务端处理慢,重试有概率成功
ConnectionClosedException连接被对端关闭可以可能是服务端空闲连接回收导致,重连可恢复
NoHttpResponseException服务端没有返回响应可以服务器可能在响应前就断开了连接
UnknownHostException域名无法解析不建议DNS 配置错误时重试不会解决问题
SSLExceptionSSL 握手失败不建议证书问题通常是持续性的,重试无用
NonRepeatableRequestException请求实体不可重放不可以流已被消费或实体超出缓冲,重试会导致异常
InterruptedIOExceptionIO 被中断不建议通常是取消或外部中断,重试违背调用方意图

这里面需要特别注意的是ConnectTimeoutExceptionConnectionPoolTimeoutException都继承自InterruptedIOException,所以如果你图省事直接对所有InterruptedIOException返回 true,会导致连接池排队超时这种“业务压力过大”的失败被无限重试,又进一步加大连接池压力。正确的做法是精确判断子类。

3.2 5xx 与 4xx 的处理差异

再强调一次:HttpAsyncRequestRetryHandler里处理不到 5xx/4xx,因为这两个是正常响应码。那 5xx 的重试应该放在哪里?

我习惯的做法是:网络层重试交给重试处理器,业务层的 5xx 重试放在响应处理阶段。具体来说有两种模式:

模式一:同步阻塞式获取响应

如果异步客户端在你的代码里还是通过future.get()来拿结果,那你可以直接在拿到HttpResponse后判断状态码:

HttpResponse response = future.get(5, TimeUnit.SECONDS); int code = response.getStatusLine().getStatusCode(); if (code >= 500) { // 走业务重试逻辑 } else if (code >= 400 && code < 500) { // 记日志,不重试,直接抛业务异常 }

这种模式简单直接,但违背了异步的初衷——future.get()会阻塞线程。建议只在异步化改造初期的过渡阶段使用。

模式二:异步回调里判断状态码

如果在回调里拿到响应,就需要自己在回调内部实现“重试 N 次”的状态机。因为重试需要再次发起请求,而且需要控制重试间隔,这不是retryRequest能给你做的。通常的做法是封装一个retryableAsyncExecute方法,内部用递归或者循环计数的方式,在回调里判断状态码,5xx 且未超过最大重试次数时,延迟一段时间后重新发起请求。

这里想强调一个很容易踩的坑:很多人在回调里拿到 5xx 后,选择再调一次同一个HttpAsyncClientexecute,然后在外层再挂一个回调。第一次写没什么问题,但如果重试次数是 3 次,回调就会嵌套 3 层。一旦后续接了熔断或者链路追踪,排查问题的时候会非常痛苦。建议提前封装好重试模型,不要把重试逻辑散落在业务代码里。

3.3 完整代码实现

下面是一个我实际在用、比较通用的重试处理器。它把网络层异常和响应码异常做了统一封装:

public class ApiRetryStrategy implements HttpAsyncRequestRetryHandler { private static final int MAX_RETRY_COUNT = 3; @Override public boolean retryRequest(IOException exception, int executionCount, HttpContext context) { // 1. 超过最大重试次数,不再重试 if (executionCount > MAX_RETRY_COUNT) { return false; } // 2. 请求实体不可重放时,绝对不能重试 if (exception instanceof NonRepeatableRequestException) { return false; } // 3. 可恢复的网络异常,统一重试 if (exception instanceof ConnectTimeoutException || exception instanceof ConnectionPoolTimeoutException || exception instanceof SocketTimeoutException || exception instanceof ConnectionClosedException || exception instanceof NoHttpResponseException) { return true; } // 4. 根据请求方法再做一次幂等性约束 Object reqObj = context.getAttribute(HttpCoreContext.HTTP_REQUEST); if (reqObj instanceof HttpUriRequest) { String method = ((HttpUriRequest) reqObj).getMethod(); if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)) { return true; } } return false; } }

这里对 GET/HEAD 请求做了一个兜底的重试允许。原因是第 3 步只覆盖了几类明确的网络异常,但如果出现未知的IOException,对于幂等的 GET 请求来说,重试的代价很低,风险也可控;而对于 POST 这类非幂等请求,宁可多报错,也不要重复提交。

然后在构建客户端时挂上去:

CloseableHttpAsyncClient client = HttpAsyncClients.custom() .setRetryHandler(new ApiRetryStrategy()) .setMaxConnPerRoute(50) .setMaxConnTotal(200) .setConnectionTimeToLive(30, TimeUnit.SECONDS) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(3000) .setSocketTimeout(5000) .setConnectionRequestTimeout(2000) .build()) .build(); client.start();

至于 5xx 的业务重试,我封装了一个简单的工具方法,思路是传入一个“异步任务”的生成器,内部用Future阻塞判断响应状态码,但这里的Future不是httpClient.execute返回的那个,而是自己定义的一个结果回传容器。你也可以直接使用CountDownLatch来实现更可控的等待,这里不展开太多,重点是状态码判定逻辑:

private boolean isRetryableHttpCode(int statusCode) { if (statusCode == 429) { return true; // 限流,配合 Retry-After 使用 } return statusCode >= 500 && statusCode < 600; }

4. 异步场景下的重试“坑位”:请求体不可重放与重试风暴

4.1 NonRepeatableRequestException 与可重放判断

前面提到了NonRepeatableRequestException,这个异常在同步HttpClient里也有,但在异步里更隐蔽。它是一个IOException的子类,所以如果你在自定义重试处理器里不对它单独判断,按照默认逻辑对IOException一律重试,那第二次发送时会再次抛异常,而且这次的异常不是原来的原因,而是一个“我无法重新读取请求体”的异常,非常误导排查方向。

什么情况下会触发?最常见的是传递了InputStreamEntity,或者使用了FileEntity但文件流已经被读过一遍。HttpAsyncClient在发送请求体时,是从HttpAsyncRequestProducer里获取内容的,如果这个 producer 生成的实体不是RepeatableEntity,第一次消费之后内部缓冲区或文件流就已经被读完。重试时执行器会尝试重新创建请求,但 producer 无法再生成同样的内容,就会直接抛出NonRepeatableRequestException

解决思路有两个:

  1. 尽量使用ByteArrayEntityStringEntity,这类实体直接从内存字节数组读取,天然可重放。对于需要上传小文件或不超过几十 MB 的数据,这是最省心的方案。

  2. 如果你的场景确实需要流式发送大文件,那就不要在retryRequest里对包含不可重放实体的请求开重试授权。你能在处理器里拿到HttpAsyncRequestProducer,可以通过检查实体类型来决定是否允许重试:

Object producerObj = context.getAttribute(HttpCoreContext.HTTP_REQUEST); // 如果 producer 里的实体不可重放,请求执行器会在抛出的异常里携带 NonRepeatableRequestException

更推荐的做法是:在上层发起请求前就判断业务是否需要重试,如果请求体来自一次性流且当前网络环境不稳定,干脆直接告知调用方“本次请求可能无法重试”,把选择权交给上层。

4.2 重试风暴控制与退避等待

另一个容易被忽略的问题:重试风暴。单个请求重试 3 次不算什么,但一个服务在高峰期每秒要发出几千个请求,如果每个请求在失败后都立即重试,瞬时流量会从 3000 QPS 放大到 12000 QPS,这对下游服务的冲击是致命的。

控制手段大概有这几层:

限制全局重试次数。不能光看每个请求的重试次数,还要看单位时间窗口内的总重试次数。可以在重试处理器里维护一个并发计数器或者令牌桶,超过阈值就拒绝重试。要注意多线程并发访问计数器的线程安全,最简单的是使用AtomicInteger

退避等待。异步场景下,在重试处理器里直接Thread.sleep()是绝对不行的,它会阻塞 NIO 的 worker 线程,直接影响所有其他请求的处理。正确的做法是在业务层用延迟调度。比如拿到 5xx 后,使用ScheduledExecutorService延迟 200ms / 500ms / 1000ms 再提交下一次请求:

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); private void retryWithBackoff(Runnable task, int retryCount) { long delay = (long) (Math.pow(2, retryCount) * 100); scheduler.schedule(task, delay, TimeUnit.MILLISECONDS); }

这里用了指数退避,第一次重试延迟 100ms,第二次 200ms,第三次 400ms。指数退避的意义在于:服务端恢复通常需要一点时间,固定间隔重试要么在服务端还未恢复时白白浪费请求,要么在服务端已经恢复后仍然傻等。

引入随机抖动。如果所有请求都按照 100ms、200ms、400ms 的节奏退避,那实际上是“打了时间差的同步重试”,依然会在同一秒内集中冲击。随机抖动就是在退避值上加一个随机偏移,让重试时间在 80ms-120ms、180ms-220ms 这个区间散开。这个细节在做过大流量的服务里特别重要。

熔断兜底。如果连续多次重试仍然失败,说明服务端可能处于长时间的不可用状态,这时候应该触发熔断,而不是无脑重试直到超时。

5. 用本地实验验证重试判定结果

5.1 构造可控的测试环境

纸上谈兵没意思,我建议你在本地搭一个最简单的验证环境。不需要复杂的框架,直接用一个轻量级的 HTTP 服务,根据请求次数返回不同的状态码即可。下面是一个基于 JDK 自带com.sun.net.httpserver.HttpServer的模拟服务,方便快速复现:

public class MockServer { private static int requestCount = 0; public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(8090), 0); server.createContext("/test", exchange -> { requestCount++; if (requestCount == 1) { // 第一次请求返回 500,模拟服务端瞬时故障 byte[] body = "{\"error\":\"internal\"}".getBytes(); exchange.sendResponseHeaders(500, body.length); exchange.getResponseBody().write(body); } else { byte[] body = "{\"ok\":true}".getBytes(); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); } exchange.close(); }); server.start(); System.out.println("Mock server started on 8090"); } }

这个服务的逻辑很简单:第一次请求固定返回 500,第二次及以后返回 200。如果你的调用方做了业务层 5xx 重试,连着请求两次,整个链路应该是成功的;如果没做重试,第一次 500 就直接失败。

再准备一个接口,固定返回 400:

server.createContext("/bad", exchange -> { byte[] body = "{\"error\":\"bad request\"}".getBytes(); exchange.sendResponseHeaders(400, body.length); exchange.getResponseBody().write(body); });

这个接口用来验证“4xx 不重试”的预期行为。

5.2 观察结果与验收清单

用上一节的重试处理器跑一遍,重点看几个现象:

  • 请求/test时,第一次返回 500,业务重试导致第二次请求成功,最终结果是 200。日志里能看到两次请求记录,第一次status=500,第二次status=200
  • 请求/bad时,只发出去一次请求,状态码 400,日志里没有第二次请求记录。这符合“4xx 不可重试”的预期。
  • /test接口第一次请求时人为制造一个SocketTimeoutException(比如让服务端 sleep 超过读超时时间),观察网络层重试处理器是否介入。预期能看到retryRequest被调用,并且第二次请求成功。

如果发现retryRequest根本没被调用,不要慌,先检查触发条件是不是IOException。5xx 响应不会触发它,这是设计使然,不是 bug。

还有一个建议:在日志里把每次重试的原因打印出来。自定义处理器里加上日志非常值得,这样线上排查时你能一眼看出这次重试到底是网络异常还是业务状态码触发的。否则重试了但不知道重试原因,跟盲人摸象没有区别。

我个人在实际排查过程中发现,很多“重试无效”的反馈最后都指向一个原因:请求体不可重放导致的隐式失败,而异常又在回调链路上被吞掉了。所以如果你在设计上选择让HttpAsyncClient自动重试,强烈建议在重试处理器里打印exception的堆栈和生产者的实体类型;如果你选择业务层重试,则建议统一封装重试工具,不要在每个业务方法里手写for循环包重试,那种代码改起来太痛苦了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询