211 lines
9.0 KiB
Java
211 lines
9.0 KiB
Java
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());
|
||
// JDK 在负载下可能抛 java.util.concurrent.TimeoutException 或 java.net.http.HttpTimeoutException,
|
||
// 两者同属"超时";按超时语义断言而非绑定具体类型。
|
||
assertTrue(isTimeoutRoot(ex), "根因应为超时异常,实际 " + 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;
|
||
}
|
||
}
|