Files
crawler-plugin/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientTimeoutEffectiveTest.java
T

211 lines
9.0 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
// 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;
}
}