Files
crawler-plugin/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java
T
huangzd1997 05da03c5ba task-176: 重试计数与日志字段(IdempotentRetryGuard 观测扩展:WARN 日志字段 + aiimage.http.retry 指标)+ 8 条测试
- 重试/最终失败打 WARN:client/url(去 query)/method/retry/耗时/延迟/原因类名,不打印含敏感信息的异常消息体
- 重试记 aiimage.http.retry 指标(method/client 标签),Micrometer 注册表可选,无注册表静默跳过
- 保留 task-172 原 API(构造器/execute 兼容),原 8 条测试不受影响;守卫仍不接调用点
2026-09-04 23:29:13 +08:00

180 lines
7.7 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}