task-173: 超时生效测试(HttpClientTimeoutEffectiveTest,本机慢端点 mock)+ 8 条测试
- 验证 HttpClientPool.requestFactory(readTimeout) 真实管线:慢端点超时内失败、快/预算内响应不误杀、时长随配置缩放、非正值钳制、连接超时生效、默认值生效 - 核实并记录平台边界:JDK 请求超时封顶"到响应头"时长,流式 body 读取不受请求超时约束(不编造总时长封顶断言) - 纯测试,零生产代码改动,全本地确定性无外部网络
This commit is contained in:
+208
@@ -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 10,mock 慢端点)。
|
||||
*
|
||||
* 用本机回环 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user