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; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.util.Locale; import java.util.Locale;
/** /**
* 幂等重试守卫(task-172)。 * 幂等重试守卫(task-172 策略 + task-176 观测)。
* *
* 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误 * 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误
* IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。 * IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。
* 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶, * 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶,
* 总尝试次数严格受配置上限约束,防止重试风暴。 * 总尝试次数严格受配置上限约束,防止重试风暴。
* *
* 仅提供策略组件,不接任何调用点;后续任务 176 的重试日志/指标在此之上扩展。 * 观测(task-176):每次重试打 WARN 日志(client/url/method/retry/耗时/延迟/原因类名,
* 不打印可能含敏感信息的异常消息),重试计数记 aiimage.http.retry 指标(Micrometer 可选,
* 未配置注册表时静默跳过)。仅提供策略组件,不接任何调用点。
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor
public class IdempotentRetryGuard { public class IdempotentRetryGuard {
/** 退避封顶(毫秒),避免长时间重试堆积。 */ /** 退避封顶(毫秒),避免长时间重试堆积。 */
static final long MAX_BACKOFF_MILLIS = 5_000L; static final long MAX_BACKOFF_MILLIS = 5_000L;
private final HttpClientProperties httpClientProperties; private final HttpClientProperties httpClientProperties;
private final MeterRegistry meterRegistry; // 可为 null:未配置 Micrometer 时静默跳过
/** 结果供给:允许抛异常,由守卫决定是否重试。 */ /** 结果供给:允许抛异常,由守卫决定是否重试。 */
@FunctionalInterface @FunctionalInterface
@@ -33,6 +38,22 @@ public class IdempotentRetryGuard {
T get() throws Exception; 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。 */ /** 是否允许自动重试:显式幂等标记优先,其次仅 GET/HEAD。 */
public boolean isIdempotent(String httpMethod, boolean explicitIdempotent) { public boolean isIdempotent(String httpMethod, boolean explicitIdempotent) {
if (explicitIdempotent) { if (explicitIdempotent) {
@@ -71,29 +92,88 @@ public class IdempotentRetryGuard {
return false; 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 最后一次失败的异常 * @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(); int maxRetries = httpClientProperties.effectiveMaxRetries();
boolean idempotent = isIdempotent(httpMethod, explicitIdempotent); boolean idempotent = isIdempotent(httpMethod, explicitIdempotent);
int retryCount = 0; int retryCount = 0;
while (true) { while (true) {
long attemptStartedAt = System.nanoTime();
try { try {
return attempt.get(); return attempt.get();
} catch (Exception e) { } catch (Exception e) {
long attemptDurationMs = (System.nanoTime() - attemptStartedAt) / 1_000_000;
boolean canRetry = idempotent && isTransportError(e) && retryCount < maxRetries; boolean canRetry = idempotent && isTransportError(e) && retryCount < maxRetries;
if (!canRetry) { 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; throw e;
} }
retryCount++; retryCount++;
long delay = backoffMillis(retryCount); long delay = backoffMillis(retryCount);
log.warn("[http-retry-guard] 调用失败即将重试 method={} retry={}/{} delayMs={} cause={}", recordRetryMetric(httpMethod, client);
httpMethod, retryCount, maxRetries, delay, e.toString()); 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); 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();
}
} }
@@ -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;
}
}