task-172: 幂等重试守卫(仅 GET/HEAD 或显式幂等标记在传输错误时自动重试,次数/退避取自配置并封顶)+ 8 条测试
- IdempotentRetryGuard:isIdempotent/backoffMillis/isTransportError/execute 纯策略组件 - 非幂等(POST 等)不自动重试;退避 2 次幂增长、5s 封顶;总尝试严格受 aiimage.http-client.max-retries 约束防风暴 - 仅新增类与测试,不接调用点,零生产行为变化
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 幂等重试守卫(task-172)。
|
||||
*
|
||||
* 统一重试策略:仅幂等请求(GET/HEAD,或调用方显式声明幂等)在网络传输错误
|
||||
* (IOException 及包装链)时自动重试;POST 等非幂等请求不自动重试,避免重复提交副作用。
|
||||
* 重试次数与基础退避取自 aiimage.http-client.* 命名空间;退避按 2 的幂增长并以 5s 封顶,
|
||||
* 总尝试次数严格受配置上限约束,防止重试风暴。
|
||||
*
|
||||
* 仅提供策略组件,不接任何调用点;后续任务 176 的重试日志/指标在此之上扩展。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class IdempotentRetryGuard {
|
||||
|
||||
/** 退避封顶(毫秒),避免长时间重试堆积。 */
|
||||
static final long MAX_BACKOFF_MILLIS = 5_000L;
|
||||
|
||||
private final HttpClientProperties httpClientProperties;
|
||||
|
||||
/** 结果供给:允许抛异常,由守卫决定是否重试。 */
|
||||
@FunctionalInterface
|
||||
public interface CheckedSupplier<T> {
|
||||
T get() throws Exception;
|
||||
}
|
||||
|
||||
/** 是否允许自动重试:显式幂等标记优先,其次仅 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的执行入口。idempotent 且传输错误时按配置重试,其余情况原样抛出。
|
||||
*
|
||||
* @throws Exception 最后一次失败的异常
|
||||
*/
|
||||
public <T> T execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier<T> attempt) throws Exception {
|
||||
int maxRetries = httpClientProperties.effectiveMaxRetries();
|
||||
boolean idempotent = isIdempotent(httpMethod, explicitIdempotent);
|
||||
int retryCount = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return attempt.get();
|
||||
} catch (Exception e) {
|
||||
boolean canRetry = idempotent && isTransportError(e) && retryCount < maxRetries;
|
||||
if (!canRetry) {
|
||||
throw e;
|
||||
}
|
||||
retryCount++;
|
||||
long delay = backoffMillis(retryCount);
|
||||
log.warn("[http-retry-guard] 调用失败即将重试 method={} retry={}/{} delayMs={} cause={}",
|
||||
httpMethod, retryCount, maxRetries, delay, e.toString());
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* task-172:幂等重试守卫契约(plan 10)。
|
||||
*
|
||||
* 统一重试策略:仅幂等请求(GET/HEAD 或显式幂等标记)在网络传输错误(IOException 及包装链)
|
||||
* 时自动重试;非幂等(POST 等)不自动重试;重试次数与基础退避取自 aiimage.http-client.*
|
||||
* 命名空间;退避指数增长并以 5s 封顶,防止重试风暴。守卫仅作为策略组件,不接任何调用点。
|
||||
*/
|
||||
class IdempotentRetryGuardTest {
|
||||
|
||||
private HttpClientProperties properties;
|
||||
private IdempotentRetryGuard guard;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new HttpClientProperties();
|
||||
guard = new IdempotentRetryGuard(properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getIsRetriedOnTransportError() throws Exception {
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
String result = guard.execute("GET", false, () -> {
|
||||
if (attempts.incrementAndGet() < 2) {
|
||||
throw new IOException("连接中断");
|
||||
}
|
||||
return "ok";
|
||||
});
|
||||
assertEquals("ok", result);
|
||||
assertEquals(2, attempts.get(), "GET 传输错误应自动重试一次");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postIsNotRetried() {
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
assertThrows(IOException.class, () -> guard.execute("POST", false, () -> {
|
||||
attempts.incrementAndGet();
|
||||
throw new IOException("连接中断");
|
||||
}));
|
||||
assertEquals(1, attempts.get(), "POST 非幂等不应自动重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryCountIsLimitedByConfig() {
|
||||
properties.setMaxRetries(3);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
assertThrows(IOException.class, () -> guard.execute("GET", false, () -> {
|
||||
attempts.incrementAndGet();
|
||||
throw new IOException("连接中断");
|
||||
}));
|
||||
assertEquals(4, attempts.get(), "1 次初始 + 3 次重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backoffIsExponentialAndCapped() {
|
||||
properties.setBaseRetryDelayMillis(500);
|
||||
assertEquals(500L, guard.backoffMillis(1));
|
||||
assertEquals(1_000L, guard.backoffMillis(2));
|
||||
assertEquals(2_000L, guard.backoffMillis(3));
|
||||
assertEquals(4_000L, guard.backoffMillis(4));
|
||||
assertEquals(5_000L, guard.backoffMillis(5), "超过 5s 应封顶");
|
||||
|
||||
properties.setBaseRetryDelayMillis(100);
|
||||
assertEquals(100L, guard.backoffMillis(1));
|
||||
assertEquals(200L, guard.backoffMillis(2));
|
||||
assertTrue(guard.backoffMillis(1) <= guard.backoffMillis(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitIdempotentFlagOverridesMethod() throws Exception {
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
String result = guard.execute("POST", true, () -> {
|
||||
if (attempts.incrementAndGet() < 2) {
|
||||
throw new IOException("连接中断");
|
||||
}
|
||||
return "ok";
|
||||
});
|
||||
assertEquals("ok", result);
|
||||
assertEquals(2, attempts.get(), "显式幂等标记应允许重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
void succeedsAfterMultipleFailures() throws Exception {
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
String result = guard.execute("GET", false, () -> {
|
||||
if (attempts.incrementAndGet() < 3) {
|
||||
throw new IOException("连接中断");
|
||||
}
|
||||
return "done";
|
||||
});
|
||||
assertEquals("done", result);
|
||||
assertEquals(3, attempts.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportErrorClassification() {
|
||||
assertTrue(guard.isTransportError(new IOException("boom")));
|
||||
assertTrue(guard.isTransportError(new RuntimeException(new IOException("cause"))),
|
||||
"包装链中含 IOException 应视为传输错误");
|
||||
assertFalse(guard.isTransportError(new IllegalStateException("业务错误")));
|
||||
assertFalse(guard.isTransportError(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRetryStormBeyondConfiguredMax() {
|
||||
properties.setMaxRetries(6);
|
||||
properties.setBaseRetryDelayMillis(1);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
assertThrows(IOException.class, () -> guard.execute("GET", false, () -> {
|
||||
attempts.incrementAndGet();
|
||||
throw new IOException("连接中断");
|
||||
}));
|
||||
assertEquals(7, attempts.get(), "总尝试次数严格受配置上限约束,不跑飞");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user