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 条测试不受影响;守卫仍不接调用点
This commit is contained in:
2026-09-04 23:29:13 +08:00
parent 02cff8ea63
commit 05da03c5ba
2 changed files with 295 additions and 8 deletions
@@ -1,31 +1,36 @@
package com.nanri.aiimage.config;
import lombok.RequiredArgsConstructor;
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-172 策略 + task-176 观测)。
*
* 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误
* IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。
* 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶,
* 总尝试次数严格受配置上限约束,防止重试风暴。
*
* 仅提供策略组件,不接任何调用点;后续任务 176 的重试日志/指标在此之上扩展。
* 观测(task-176):每次重试打 WARN 日志(client/url/method/retry/耗时/延迟/原因类名,
* 不打印可能含敏感信息的异常消息),重试计数记 aiimage.http.retry 指标(Micrometer 可选,
* 未配置注册表时静默跳过)。仅提供策略组件,不接任何调用点。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class IdempotentRetryGuard {
/** 退避封顶(毫秒),避免长时间重试堆积。 */
static final long MAX_BACKOFF_MILLIS = 5_000L;
private final HttpClientProperties httpClientProperties;
private final MeterRegistry meterRegistry; // 可为 null:未配置 Micrometer 时静默跳过
/** 结果供给:允许抛异常,由守卫决定是否重试。 */
@FunctionalInterface
@@ -33,6 +38,22 @@ public class IdempotentRetryGuard {
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) {
@@ -71,29 +92,88 @@ public class IdempotentRetryGuard {
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 且传输错误时按配置重试,其余情况原样抛出。
* 带重试与观测的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。
*
* @param client 客户端标识(可选),用于日志与指标标签
* @param url 目标 URL(可选),仅用于日志定位,不打印 query/headers
* @throws Exception 最后一次失败的异常
*/
public <T> T execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier<T> attempt) 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);
log.warn("[http-retry-guard] 调用失败即将重试 method={} retry={}/{} delayMs={} cause={}",
httpMethod, retryCount, maxRetries, delay, e.toString());
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();
}
}