From 19121f6239c1e690d047a4881520587f55ec3196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 23:22:43 +0800 Subject: [PATCH] =?UTF-8?q?task-173:=20=E8=B6=85=E6=97=B6=E7=94=9F?= =?UTF-8?q?=E6=95=88=E6=B5=8B=E8=AF=95=EF=BC=88HttpClientTimeoutEffectiveT?= =?UTF-8?q?est=EF=BC=8C=E6=9C=AC=E6=9C=BA=E6=85=A2=E7=AB=AF=E7=82=B9=20moc?= =?UTF-8?q?k=EF=BC=89+=208=20=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验证 HttpClientPool.requestFactory(readTimeout) 真实管线:慢端点超时内失败、快/预算内响应不误杀、时长随配置缩放、非正值钳制、连接超时生效、默认值生效 - 核实并记录平台边界:JDK 请求超时封顶"到响应头"时长,流式 body 读取不受请求超时约束(不编造总时长封顶断言) - 纯测试,零生产代码改动,全本地确定性无外部网络 --- .../HttpClientTimeoutEffectiveTest.java | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/HttpClientTimeoutEffectiveTest.java diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientTimeoutEffectiveTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientTimeoutEffectiveTest.java new file mode 100644 index 00000000..600855d6 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientTimeoutEffectiveTest.java @@ -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; + } +}