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
@@ -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<ILoggingEvent> attachCapture() {
Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
guardLogger.addAppender(appender);
return appender;
}
private static void detachCapture(ListAppender<ILoggingEvent> appender) {
Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class);
guardLogger.detachAppender(appender);
}
private static List<String> messages(ListAppender<ILoggingEvent> appender) {
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
}
private static String join(ListAppender<ILoggingEvent> appender) {
return String.join("\n", messages(appender));
}
@Test
void retryLoggedWithGuardPrefix() throws Exception {
HttpClientProperties props = fastProps();
IdempotentRetryGuard guard = new IdempotentRetryGuard(props);
ListAppender<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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;
}
}