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 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 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 execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier attempt) throws Exception { return execute(httpMethod, explicitIdempotent, null, null, attempt); } /** * 带重试与观测的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。 * * @param client 客户端标识(可选),用于日志与指标标签 * @param url 目标 URL(可选),仅用于日志定位,不打印 query/headers * @throws Exception 最后一次失败的异常 */ public T execute(String httpMethod, boolean explicitIdempotent, String client, String url, CheckedSupplier 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(); } }