From 05da03c5ba7bce921ea398d32f1756001ebd85e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 23:29:13 +0800 Subject: [PATCH] =?UTF-8?q?task-176:=20=E9=87=8D=E8=AF=95=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E6=97=A5=E5=BF=97=E5=AD=97=E6=AE=B5=EF=BC=88?= =?UTF-8?q?IdempotentRetryGuard=20=E8=A7=82=E6=B5=8B=E6=89=A9=E5=B1=95?= =?UTF-8?q?=EF=BC=9AWARN=20=E6=97=A5=E5=BF=97=E5=AD=97=E6=AE=B5=20+=20aiim?= =?UTF-8?q?age.http.retry=20=E6=8C=87=E6=A0=87=EF=BC=89+=208=20=E6=9D=A1?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重试/最终失败打 WARN:client/url(去 query)/method/retry/耗时/延迟/原因类名,不打印含敏感信息的异常消息体 - 重试记 aiimage.http.retry 指标(method/client 标签),Micrometer 注册表可选,无注册表静默跳过 - 保留 task-172 原 API(构造器/execute 兼容),原 8 条测试不受影响;守卫仍不接调用点 --- .../aiimage/config/IdempotentRetryGuard.java | 96 +++++++- ...IdempotentRetryGuardObservabilityTest.java | 207 ++++++++++++++++++ 2 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardObservabilityTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java b/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java index e8fe81f6..25f2340e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java @@ -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 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 execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier attempt) throws Exception { + return execute(httpMethod, explicitIdempotent, null, null, attempt); + } + /** - * 带重试的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。 + * 带重试与观测的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。 * + * @param client 客户端标识(可选),用于日志与指标标签 + * @param url 目标 URL(可选),仅用于日志定位,不打印 query/headers * @throws Exception 最后一次失败的异常 */ - public T execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier attempt) 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); - 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(); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardObservabilityTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardObservabilityTest.java new file mode 100644 index 00000000..890ceaa2 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardObservabilityTest.java @@ -0,0 +1,207 @@ +package com.nanri.aiimage.config; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-176:重试计数与日志字段契约(plan 10,扩展 task-172 守卫的观测面)。 + * + * 守卫每次重试打 WARN 日志(client/url/method/retry/耗时时长/延迟/原因类名),重试记 + * aiimage.http.retry 指标(Micrometer 可选);成功不记重试日志;日志不打印可能含敏感 + * 信息的异常消息体。 + */ +class IdempotentRetryGuardObservabilityTest { + + private static ListAppender attachCapture() { + Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + guardLogger.addAppender(appender); + return appender; + } + + private static void detachCapture(ListAppender appender) { + Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class); + guardLogger.detachAppender(appender); + } + + private static List messages(ListAppender appender) { + return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + private static String join(ListAppender appender) { + return String.join("\n", messages(appender)); + } + + @Test + void retryLoggedWithGuardPrefix() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("GET", false, "brand", "http://example/brand/check", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + assertTrue(join(captured).contains("http-retry-guard"), "重试应打 [http-retry-guard] 日志"); + } finally { + detachCapture(captured); + } + } + + @Test + void retryLogCarriesClientUrlMethodAndAttemptFields() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("GET", false, "brand", "http://example/brand/check", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + String text = join(captured); + assertTrue(text.contains("client=brand"), "应含 client 字段"); + assertTrue(text.contains("url=http://example/brand/check"), "应含 url 字段"); + assertTrue(text.contains("method=GET"), "应含 method 字段"); + assertTrue(text.contains("retry=1/3"), "应含重试计数与上限字段"); + } finally { + detachCapture(captured); + } + } + + @Test + void retryLogCarriesDurationAndDelay() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("GET", false, () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + String text = join(captured); + assertTrue(text.contains("durationMs="), "应含耗时字段"); + assertTrue(text.contains("delayMs="), "应含延迟字段"); + } finally { + detachCapture(captured); + } + } + + @Test + void retryMetricRecorded() throws Exception { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props, registry); + AtomicInteger n = new AtomicInteger(); + guard.execute("GET", false, "ziniao", "http://ziniao/x", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + assertEquals(1.0, registry.get("aiimage.http.retry").counter().count(), "一次重试应计数 1"); + assertEquals("GET", registry.get("aiimage.http.retry").counter().getId().getTag("method")); + assertEquals("ziniao", registry.get("aiimage.http.retry").counter().getId().getTag("client")); + } + + @Test + void noSensitiveInfoInRetryLog() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("POST", true, "brand", "http://example/brand/check?token=secretQuery", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("响应含 Bearer secret-token-abc"); + } + return "ok"; + }); + String text = join(captured); + assertTrue(!text.contains("secret"), "日志不应含异常消息里的敏感串,实际: " + text); + assertTrue(!text.contains("Bearer"), "日志不应含 Authorization 字样"); + } finally { + detachCapture(captured); + } + } + + @Test + void successWithoutRetryLogsNothing() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + guard.execute("GET", false, "brand", "http://example/brand/check", () -> "ok"); + assertEquals(0, captured.list.size(), "成功不应产生任何重试日志"); + } finally { + detachCapture(captured); + } + } + + @Test + void retryLogLevelIsWarn() throws Exception { + HttpClientProperties props = fastProps(); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("GET", false, () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + assertTrue(!captured.list.isEmpty()); + assertEquals(Level.WARN.toString(), captured.list.get(0).getLevel().toString(), + "重试日志应为 WARN 级别"); + } finally { + detachCapture(captured); + } + } + + @Test + void finalFailureLoggedAfterRetryExhausted() throws Exception { + HttpClientProperties props = fastProps(); + props.setMaxRetries(2); + IdempotentRetryGuard guard = new IdempotentRetryGuard(props); + ListAppender captured = attachCapture(); + try { + assertThrows(IOException.class, () -> guard.execute("GET", false, "brand", "http://example/x", () -> { + throw new IOException("连接中断"); + })); + String text = join(captured); + assertTrue(text.contains("最终失败"), "重试耗尽应记录最终失败,实际: " + text); + assertTrue(text.contains("retry=2/2"), "最终失败应携带已重试次数"); + } finally { + detachCapture(captured); + } + } + + private static HttpClientProperties fastProps() { + HttpClientProperties props = new HttpClientProperties(); + props.setBaseRetryDelayMillis(1); + return props; + } +}