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:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+207
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user