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();
+ }
+}