05da03c5ba
- 重试/最终失败打 WARN:client/url(去 query)/method/retry/耗时/延迟/原因类名,不打印含敏感信息的异常消息体 - 重试记 aiimage.http.retry 指标(method/client 标签),Micrometer 注册表可选,无注册表静默跳过 - 保留 task-172 原 API(构造器/execute 兼容),原 8 条测试不受影响;守卫仍不接调用点
180 lines
7.7 KiB
Java
180 lines
7.7 KiB
Java
package com.nanri.aiimage.config;
|
||
|
||
import io.micrometer.core.instrument.Counter;
|
||
import io.micrometer.core.instrument.MeterRegistry;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.ObjectProvider;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
import java.io.IOException;
|
||
import java.util.Locale;
|
||
|
||
/**
|
||
* 幂等重试守卫(task-172 策略 + task-176 观测)。
|
||
*
|
||
* 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误
|
||
* (IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。
|
||
* 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶,
|
||
* 总尝试次数严格受配置上限约束,防止重试风暴。
|
||
*
|
||
* 观测(task-176):每次重试打 WARN 日志(client/url/method/retry/耗时/延迟/原因类名,
|
||
* 不打印可能含敏感信息的异常消息),重试计数记 aiimage.http.retry 指标(Micrometer 可选,
|
||
* 未配置注册表时静默跳过)。仅提供策略组件,不接任何调用点。
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
public class IdempotentRetryGuard {
|
||
|
||
/** 退避封顶(毫秒),避免长时间重试堆积。 */
|
||
static final long MAX_BACKOFF_MILLIS = 5_000L;
|
||
|
||
private final HttpClientProperties httpClientProperties;
|
||
private final MeterRegistry meterRegistry; // 可为 null:未配置 Micrometer 时静默跳过
|
||
|
||
/** 结果供给:允许抛异常,由守卫决定是否重试。 */
|
||
@FunctionalInterface
|
||
public interface CheckedSupplier<T> {
|
||
T get() throws Exception;
|
||
}
|
||
|
||
public IdempotentRetryGuard(HttpClientProperties httpClientProperties) {
|
||
this(httpClientProperties, (MeterRegistry) null);
|
||
}
|
||
|
||
public IdempotentRetryGuard(HttpClientProperties httpClientProperties, MeterRegistry meterRegistry) {
|
||
this.httpClientProperties = httpClientProperties;
|
||
this.meterRegistry = meterRegistry;
|
||
}
|
||
|
||
/** 生产构造:Micrometer 注册表可选(无注册表时静默跳过指标)。 */
|
||
@Autowired
|
||
public IdempotentRetryGuard(HttpClientProperties httpClientProperties,
|
||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||
this(httpClientProperties, meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable());
|
||
}
|
||
|
||
/** 是否允许自动重试:显式幂等标记优先,其次仅 GET/HEAD。 */
|
||
public boolean isIdempotent(String httpMethod, boolean explicitIdempotent) {
|
||
if (explicitIdempotent) {
|
||
return true;
|
||
}
|
||
if (httpMethod == null) {
|
||
return false;
|
||
}
|
||
String method = httpMethod.trim().toUpperCase(Locale.ROOT);
|
||
return "GET".equals(method) || "HEAD".equals(method);
|
||
}
|
||
|
||
/** 第 retryNumber 次重试前的等待:base * 2^(n-1),封顶 5s,最小 1ms。 */
|
||
public long backoffMillis(int retryNumber) {
|
||
long base = httpClientProperties.getBaseRetryDelayMillis();
|
||
int shift = Math.min(Math.max(retryNumber - 1, 0), 10);
|
||
long delay;
|
||
try {
|
||
delay = base * (1L << shift);
|
||
} catch (ArithmeticException e) {
|
||
delay = Long.MAX_VALUE;
|
||
}
|
||
return Math.min(Math.max(delay, 1L), MAX_BACKOFF_MILLIS);
|
||
}
|
||
|
||
/** 是否为传输层错误:自身或任一 cause 为 IOException(含连接/读超时、DNS、SSL 握手)。 */
|
||
public boolean isTransportError(Throwable error) {
|
||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||
if (current instanceof IOException) {
|
||
return true;
|
||
}
|
||
if (current.getCause() == current) {
|
||
break;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 默认形态重试执行(无 client/url 标注)。 */
|
||
public <T> T execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier<T> attempt) throws Exception {
|
||
return execute(httpMethod, explicitIdempotent, null, null, attempt);
|
||
}
|
||
|
||
/**
|
||
* 带重试与观测的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。
|
||
*
|
||
* @param client 客户端标识(可选),用于日志与指标标签
|
||
* @param url 目标 URL(可选),仅用于日志定位,不打印 query/headers
|
||
* @throws Exception 最后一次失败的异常
|
||
*/
|
||
public <T> T execute(String httpMethod, boolean explicitIdempotent, String client, String url,
|
||
CheckedSupplier<T> attempt) throws Exception {
|
||
int maxRetries = httpClientProperties.effectiveMaxRetries();
|
||
boolean idempotent = isIdempotent(httpMethod, explicitIdempotent);
|
||
int retryCount = 0;
|
||
while (true) {
|
||
long attemptStartedAt = System.nanoTime();
|
||
try {
|
||
return attempt.get();
|
||
} catch (Exception e) {
|
||
long attemptDurationMs = (System.nanoTime() - attemptStartedAt) / 1_000_000;
|
||
boolean canRetry = idempotent && isTransportError(e) && retryCount < maxRetries;
|
||
if (!canRetry) {
|
||
if (retryCount > 0) {
|
||
log.warn("[http-retry-guard] 重试耗尽,最终失败 client={} url={} method={} retry={}/{} "
|
||
+ "durationMs={} cause={}",
|
||
client, sanitizeUrl(url), httpMethod, retryCount, maxRetries, attemptDurationMs,
|
||
causeLabel(e));
|
||
}
|
||
throw e;
|
||
}
|
||
retryCount++;
|
||
long delay = backoffMillis(retryCount);
|
||
recordRetryMetric(httpMethod, client);
|
||
log.warn("[http-retry-guard] 调用失败即将重试 client={} url={} method={} retry={}/{} "
|
||
+ "delayMs={} durationMs={} cause={}",
|
||
client, sanitizeUrl(url), httpMethod, retryCount, maxRetries, delay, attemptDurationMs,
|
||
causeLabel(e));
|
||
Thread.sleep(delay);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 仅记录异常类名(含根因),不打印可能含敏感信息的消息体。 */
|
||
private static String causeLabel(Throwable error) {
|
||
Throwable root = error;
|
||
while (root.getCause() != null && root.getCause() != root) {
|
||
root = root.getCause();
|
||
}
|
||
if (root != error && root != null) {
|
||
return error.getClass().getSimpleName() + " <- " + root.getClass().getSimpleName();
|
||
}
|
||
return error == null ? "null" : error.getClass().getSimpleName();
|
||
}
|
||
|
||
/** 日志用的 URL 仅保留 scheme://host/path,剥离 query/fragment,避免误记 token 等敏感参数。 */
|
||
private static String sanitizeUrl(String url) {
|
||
if (url == null) {
|
||
return null;
|
||
}
|
||
int query = url.indexOf('?');
|
||
int fragment = url.indexOf('#');
|
||
int cut = -1;
|
||
if (query >= 0) {
|
||
cut = query;
|
||
}
|
||
if (fragment >= 0 && (cut < 0 || fragment < cut)) {
|
||
cut = fragment;
|
||
}
|
||
return cut < 0 ? url : url.substring(0, cut);
|
||
}
|
||
|
||
private void recordRetryMetric(String httpMethod, String client) {
|
||
if (meterRegistry == null) {
|
||
return;
|
||
}
|
||
Counter counter = Counter.builder("aiimage.http.retry")
|
||
.tag("method", httpMethod == null ? "unknown" : httpMethod.toUpperCase(Locale.ROOT))
|
||
.tag("client", client == null ? "unknown" : client)
|
||
.register(meterRegistry);
|
||
counter.increment();
|
||
}
|
||
}
|