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 new file mode 100644 index 00000000..e8fe81f6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/IdempotentRetryGuard.java @@ -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 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 execute(String httpMethod, boolean explicitIdempotent, CheckedSupplier 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); + } + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardTest.java new file mode 100644 index 00000000..ee009418 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/IdempotentRetryGuardTest.java @@ -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(), "总尝试次数严格受配置上限约束,不跑飞"); + } +}