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; } }