This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 7ccc26e1c5 fix(httpclient): apply global response timeout (#7265)
7ccc26e1c5 is described below

commit 7ccc26e1c5efe84435b825c4abf1f19b73e08571
Author: Liming Deng <[email protected]>
AuthorDate: Thu Oct 1 11:51:24 2026 +0800

    fix(httpclient): apply global response timeout (#7265)
---
 RELEASE-NOTES.md                                   |   9 ++
 .../httpclient/config/HttpClientProperties.java    |   7 +-
 .../plugin/httpclient/HttpClientFactory.java       |  10 +-
 .../httpclient/HttpClientResponseTimeoutTest.java  | 127 +++++++++++++++++++++
 4 files changed, 149 insertions(+), 4 deletions(-)

diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 66027048f7..620145c143 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -18,6 +18,15 @@
   count returns HTTP 408. The aggregate ceiling now also bounds a source that
   never completes; it does not replace the per-attempt response timeout.
 - The HTTP client now defaults to a fixed connection pool. Connection 
acquisition waits up to 3 seconds, and Reactor Netty bounds pending 
acquisitions to twice the configured maximum connection count. Set 
`shenyu.httpclient.pool.type=ELASTIC` to retain the previous unbounded behavior.
+- `shenyu.httpclient.responseTimeout` now configures Reactor Netty's 
response-read
+  deadline (default 3000 ms). It limits gaps between reads throughout the 
response
+  body, not just the wait for headers. Slow SSE, long-polling and token streams
+  can therefore time out after headers have arrived.
+- A non-positive responseTimeout disables that deadline only. Independently
+  configured read-timeout handlers still apply; use 
`shenyu.httpclient.readTimeout=0`
+  to disable their deadline as well, and review route, retry and caller 
deadlines.
+  The client factory now retains its connection-handler configuration, so 
configured
+  read, write and idle handlers are installed on new connections.
 
 ## [v2.7.0]- 2024-12-23
 
diff --git 
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/config/HttpClientProperties.java
 
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/config/HttpClientProperties.java
index f0a9cac2ca..dfbe4db247 100644
--- 
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/config/HttpClientProperties.java
+++ 
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/config/HttpClientProperties.java
@@ -57,7 +57,9 @@ public class HttpClientProperties {
     private Integer connectTimeout = 45000;
 
     /**
-     * The response timeout.
+     * Maximum interval in milliseconds between response reads, including body 
chunks; default 3s.
+     * Non-positive values disable this response deadline, not independently 
installed read-timeout handlers.
+     * Slow SSE/streaming responses may also require disabling readTimeout and 
adjusting route/caller deadlines.
      */
     private Long responseTimeout = 3000L;
 
@@ -77,7 +79,8 @@ public class HttpClientProperties {
     private Integer allIdleTime = 3000;
 
     /**
-     * readTimeout, the default is 3s.
+     * Connection read-timeout handler interval in milliseconds, default 3s; 
non-positive values disable the handler.
+     * When installed, this handler operates independently of responseTimeout.
      */
     private Integer readTimeout = 3000;
 
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/main/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientFactory.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/main/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientFactory.java
index d8911dfe24..e3c62f7810 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/main/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientFactory.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/main/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientFactory.java
@@ -113,6 +113,10 @@ public class HttpClientFactory extends 
AbstractFactoryBean<HttpClient> {
         ConnectionProvider connectionProvider = buildConnectionProvider(pool);
         HttpClient httpClient = HttpClient.create(connectionProvider)
                 .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 
properties.getConnectTimeout());
+        Duration responseTimeout = properties.getResponseTimeout();
+        if (!responseTimeout.isZero() && !responseTimeout.isNegative()) {
+            httpClient = httpClient.responseTimeout(responseTimeout);
+        }
         if (serverProperties.getHttp2().isEnabled()) {
             httpClient = httpClient.protocol(HttpProtocol.HTTP11, 
HttpProtocol.H2);
         }
@@ -120,10 +124,12 @@ public class HttpClientFactory extends 
AbstractFactoryBean<HttpClient> {
         if (StringUtils.isNotEmpty(proxy.getHost())) {
             httpClient = setHttpClientProxy(httpClient, proxy);
         }
-        httpClient.doOnConnected(connection -> {
+        httpClient = httpClient.doOnConnected(connection -> {
             connection.addHandlerLast(new 
IdleStateHandler(properties.getReaderIdleTime(), 
properties.getWriterIdleTime(), properties.getAllIdleTime(), 
TimeUnit.MILLISECONDS));
             connection.addHandlerLast(new 
WriteTimeoutHandler(properties.getWriteTimeout(), TimeUnit.MILLISECONDS));
-            connection.addHandlerLast(new 
ReadTimeoutHandler(properties.getReadTimeout(), TimeUnit.MILLISECONDS));
+            if (properties.getReadTimeout() > 0) {
+                connection.addHandlerLast(new 
ReadTimeoutHandler(properties.getReadTimeout(), TimeUnit.MILLISECONDS));
+            }
         });
         if (Objects.nonNull(loopResources)) {
             httpClient.runOn(loopResources);
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/test/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientResponseTimeoutTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/test/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientResponseTimeoutTest.java
new file mode 100644
index 0000000000..d179b7150e
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-httpclient/src/test/java/org/apache/shenyu/springboot/starter/plugin/httpclient/HttpClientResponseTimeoutTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package org.apache.shenyu.springboot.starter.plugin.httpclient;
+
+import io.netty.handler.timeout.ReadTimeoutException;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import org.apache.shenyu.plugin.httpclient.config.HttpClientProperties;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Flux;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.client.HttpClient;
+import reactor.netty.http.server.HttpServer;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HttpClientResponseTimeoutTest {
+
+    @Test
+    void appliesDefaultAndConfiguredResponseTimeouts() {
+        HttpClientProperties properties = new HttpClientProperties();
+        assertEquals(Duration.ofSeconds(3), 
createClient(properties).configuration().responseTimeout());
+        properties.setResponseTimeout(10000L);
+        assertEquals(Duration.ofSeconds(10), 
createClient(properties).configuration().responseTimeout());
+        properties.setResponseTimeout(null);
+        assertEquals(Duration.ofSeconds(3), 
createClient(properties).configuration().responseTimeout());
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, -1})
+    void nonPositiveTimeoutDoesNotInstallReadDeadline(final long timeout) {
+        HttpClientProperties properties = new HttpClientProperties();
+        properties.setResponseTimeout(timeout);
+        assertNull(createClient(properties).configuration().responseTimeout());
+    }
+
+    @Test
+    void responseTimeoutAlsoBoundsGapsAfterHeadersArrive() {
+        DisposableServer server = slowStreamingServer();
+        try {
+            HttpClientProperties properties = new HttpClientProperties();
+            properties.setResponseTimeout(100L);
+            AtomicBoolean headersReceived = new AtomicBoolean();
+            assertThrows(ReadTimeoutException.class, () -> 
createClient(properties).get().uri("http://127.0.0.1:"; + server.port())
+                    .response((response, body) -> {
+                        headersReceived.set(true);
+                        return body.asString();
+                    }).collectList().block(Duration.ofSeconds(5)));
+            assertTrue(headersReceived.get());
+        } finally {
+            server.disposeNow();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {-1, 0, 100})
+    void independentReadHandlerStillAppliesWhenResponseTimeoutIsDisabled(final 
int readTimeout) {
+        DisposableServer server = slowStreamingServer();
+        try {
+            HttpClientProperties properties = new HttpClientProperties();
+            properties.setResponseTimeout(0L);
+            properties.setReadTimeout(readTimeout);
+            AtomicBoolean handlerInstalled = new AtomicBoolean();
+            HttpClient client = 
createClient(properties).doOnConnected(connection ->
+                    
handlerInstalled.set(Objects.nonNull(connection.channel().pipeline().get(ReadTimeoutHandler.class))));
+            Mono<String> body = client.get().uri("http://127.0.0.1:"; + 
server.port()).responseContent().aggregate().asString();
+            if (readTimeout <= 0) {
+                assertEquals("firstlast", body.block(Duration.ofSeconds(5)));
+            } else {
+                assertThrows(ReadTimeoutException.class, () -> 
body.block(Duration.ofSeconds(5)));
+            }
+            assertEquals(readTimeout > 0, handlerInstalled.get());
+        } finally {
+            server.disposeNow();
+        }
+    }
+
+    private DisposableServer slowStreamingServer() {
+        return HttpServer.create().host("127.0.0.1").port(0).handle((request, 
response) ->
+                
response.sendString(Flux.just("first").concatWith(Mono.delay(Duration.ofMillis(400)).map(ignored
 -> "last")))).bindNow();
+    }
+
+    @Test
+    void timesOutAnUnresponsiveServer() {
+        DisposableServer server = 
HttpServer.create().host("127.0.0.1").port(0).handle((request, response) -> 
Mono.never()).bindNow();
+        try {
+            HttpClientProperties properties = new HttpClientProperties();
+            properties.setResponseTimeout(100L);
+            HttpClient client = createClient(properties);
+            assertThrows(ReadTimeoutException.class, () -> 
client.get().uri("http://127.0.0.1:"; + server.port())
+                    .response().block(Duration.ofSeconds(5)));
+        } finally {
+            server.disposeNow();
+        }
+    }
+
+    private HttpClient createClient(final HttpClientProperties properties) {
+        
properties.getPool().setType(HttpClientProperties.Pool.PoolType.DISABLED);
+        return new HttpClientFactory(properties, null, new 
ServerProperties()).createInstance();
+    }
+}

Reply via email to