task-172: 幂等重试守卫(仅 GET/HEAD 或显式幂等标记在传输错误时自动重试,次数/退避取自配置并封顶)+ 8 条测试

- IdempotentRetryGuard:isIdempotent/backoffMillis/isTransportError/execute 纯策略组件
- 非幂等(POST 等)不自动重试;退避 2 次幂增长、5s 封顶;总尝试严格受 aiimage.http-client.max-retries 约束防风暴
- 仅新增类与测试,不接调用点,零生产行为变化
This commit is contained in:
2026-09-04 23:13:41 +08:00
parent c1f03d64d1
commit e248b2b935
2 changed files with 226 additions and 0 deletions
@@ -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(), "总尝试次数严格受配置上限约束,不跑飞");
}
}