task-173: 超时生效测试(HttpClientTimeoutEffectiveTest,本机慢端点 mock)+ 8 条测试

- 验证 HttpClientPool.requestFactory(readTimeout) 真实管线:慢端点超时内失败、快/预算内响应不误杀、时长随配置缩放、非正值钳制、连接超时生效、默认值生效
- 核实并记录平台边界:JDK 请求超时封顶"到响应头"时长,流式 body 读取不受请求超时约束(不编造总时长封顶断言)
- 纯测试,零生产代码改动,全本地确定性无外部网络
This commit is contained in:
2026-09-04 23:22:43 +08:00
parent e248b2b935
commit 19121f6239
@@ -0,0 +1,208 @@
package com.nanri.aiimage.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* task-173:超时生效测试(plan 10mock 慢端点)。
*
* 用本机回环 HttpServer 模拟慢端点,验证 HttpClientPool.requestFactory(readTimeout)
* 真实管线:慢于配置超时的端点超时内失败、快/预算内响应不误伤、超时类型为
* java.util.concurrent.TimeoutException、超时时长随配置缩放、连接超时对本机不可达地址生效。
* 全部本地确定性,不发外部网络请求。
*
* 已核实的平台边界(task-166 记录):JDK 请求超时封顶的是"到响应头"的时长,
* 流式 body 读取不受请求超时约束,因此不为此编造"总时长封顶 body"的断言。
*/
class HttpClientTimeoutEffectiveTest {
private static HttpServer server;
private static ExecutorService serverExecutor;
@BeforeAll
static void startServer() throws IOException {
serverExecutor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "http-timeout-test");
t.setDaemon(true);
return t;
});
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(serverExecutor);
server.createContext("/slow", exchange -> {
String query = exchange.getRequestURI().getQuery(); // ms=N
long ms = 2_000;
if (query != null && query.startsWith("ms=")) {
ms = Long.parseLong(query.substring(3));
}
sleep(ms);
respond(exchange, "slow-done");
});
server.createContext("/fast", exchange -> respond(exchange, "ok"));
server.start();
}
@AfterAll
static void stopServer() {
if (server != null) {
server.stop(0);
}
if (serverExecutor != null) {
serverExecutor.shutdownNow();
}
}
private static void respond(com.sun.net.httpserver.HttpExchange exchange, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static String baseUrl() {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
private static RestClient clientWithReadTimeout(int readTimeoutMillis) {
return RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(readTimeoutMillis))
.build();
}
@Test
void readTimeoutFiresOnSlowEndpoint() {
RestClient client = clientWithReadTimeout(300);
long startedAt = System.nanoTime();
ResourceAccessException ex = assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=3000").retrieve().toBodilessEntity());
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(isTimeoutRoot(ex), "根因应为超时异常,实际 " + root(ex));
assertTrue(elapsedMs < 1_500, "慢端点应在配置超时附近失败,实际 " + elapsedMs + "ms");
}
@Test
void fastEndpointSucceedsWithoutTimeout() {
RestClient client = clientWithReadTimeout(300);
client.get().uri(baseUrl() + "/fast").retrieve().toBodilessEntity();
// 未抛异常即通过:快响应不应被误判超时
}
@Test
void moderatelySlowWithinBudgetSucceeds() {
// 预算内慢响应(500ms < readTimeout 3000ms)应正常返回,不得提前误杀
RestClient client = clientWithReadTimeout(3_000);
client.get().uri(baseUrl() + "/slow?ms=500").retrieve().toBodilessEntity();
}
@Test
void timeoutRootIsTimeoutException() {
RestClient client = clientWithReadTimeout(200);
ResourceAccessException ex = assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=2000").retrieve().toBodilessEntity());
assertTrue(root(ex) instanceof TimeoutException, "根因应为 java.util.concurrent.TimeoutException,实际 " + root(ex));
}
@Test
void readTimeoutScalesWithConfiguredValue() {
long elapsedShort = timeToFail(150);
long elapsedLong = timeToFail(700);
assertTrue(elapsedShort < elapsedLong,
"更小的 readTimeout 应更快失败:" + elapsedShort + "ms vs " + elapsedLong + "ms");
assertTrue(elapsedLong < 2_500, "较长配置也应在合理范围内失败");
}
@Test
void requestFactoryClampsNonPositiveReadTimeout() {
// HttpClientPool.requestFactory 把非正值钳制到最小 1ms,慢端点极快失败
RestClient client = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(-5))
.build();
long startedAt = System.nanoTime();
assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=2000").retrieve().toBodilessEntity());
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(elapsedMs < 800, "钳制到 1ms 后应极快失败,实际 " + elapsedMs + "ms");
}
@Test
void connectTimeoutFiresOnUnreachableHost() {
// 本机不可达地址(TEST-NET-1 保留段)+ 短连接超时 → 在限定时间内以传输错误失败
HttpClientProperties props = new HttpClientProperties();
props.setConnectTimeoutMillis(500);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(props.effectiveConnectTimeoutMillis()))
.build();
org.springframework.http.client.JdkClientHttpRequestFactory factory =
new org.springframework.http.client.JdkClientHttpRequestFactory(client);
RestClient rest = RestClient.builder().requestFactory(factory).build();
long startedAt = System.nanoTime();
try {
rest.get().uri("http://192.0.2.1:81/").retrieve().toBodilessEntity();
fail("不可达地址不应成功");
} catch (ResourceAccessException expected) {
// 预期:连接超时或快速不可达,均属传输错误
}
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
assertTrue(elapsedMs < 3_000, "连接超时应在限定时间内失败,实际 " + elapsedMs + "ms");
}
@Test
void sharedPoolAndNamespaceDefaultsAreOperative() {
// 连接超时来源=共享 HttpClient(10s);命名空间默认 read=60s 经 requestFactory 生效且不误伤快响应
assertEquals(Duration.ofSeconds(10), HttpClientPool.sharedHttpClient().connectTimeout().orElseThrow(),
"共享 HttpClient 连接超时为 10s");
HttpClientProperties props = new HttpClientProperties();
assertEquals(60_000L, props.effectiveReadTimeoutMillis());
RestClient client = clientWithReadTimeout((int) props.effectiveReadTimeoutMillis());
client.get().uri(baseUrl() + "/fast").retrieve().toBodilessEntity();
}
private static long timeToFail(int readTimeoutMillis) {
RestClient client = clientWithReadTimeout(readTimeoutMillis);
long startedAt = System.nanoTime();
assertThrows(ResourceAccessException.class,
() -> client.get().uri(baseUrl() + "/slow?ms=5000").retrieve().toBodilessEntity());
return (System.nanoTime() - startedAt) / 1_000_000;
}
private static Throwable root(Throwable error) {
Throwable current = error;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
return current;
}
private static boolean isTimeoutRoot(Throwable error) {
return root(error) instanceof TimeoutException
|| root(error) instanceof java.net.http.HttpTimeoutException;
}
}