Use executor for blocking I/O in Reactor request factory

Prior to this commit, the `ReactorClientHttpRequestFactory` and the
`ReactorClientHttpRequest` would use the `Executor` from the current
event loop for performing write operations.
Depending on I/O demand, this work could be blocked and would result in
blocked Netty event loop executors and the HTTP client hanging.

This commit ensures that the client uses a separate Executor for such
operations. If the application does not provide one on the request
factory, a `Schedulers#boundedElastic` instance will be used.

Fixes gh-34707
This commit is contained in:
Brian Clozel
2025-11-10 16:12:14 +01:00
parent e735c2d9c5
commit ba39385cce
2 changed files with 40 additions and 16 deletions
@@ -21,13 +21,13 @@ import java.io.UncheckedIOException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import org.reactivestreams.FlowAdapters;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.netty.NettyOutbound;
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.HttpClientRequest;
@@ -43,6 +43,7 @@ import org.springframework.util.StreamUtils;
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Brian Clozel
* @since 6.1
*/
final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest {
@@ -53,6 +54,8 @@ final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest
private final URI uri;
private final Executor executor;
@Nullable
private final Duration exchangeTimeout;
@@ -65,19 +68,31 @@ final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest
* @since 6.2
*/
public ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri) {
this.httpClient = httpClient;
this.method = method;
this.uri = uri;
this.exchangeTimeout = null;
this(httpClient, method, uri, null);
}
/**
* Create an instance.
* <p>If no executor is provided, the request will use an {@link Schedulers#boundedElastic() elastic scheduler}
* for performing blocking I/O operations.
* @param httpClient the client to perform the request with
* @param executor the executor to use
* @param method the HTTP method
* @param uri the URI for the request
* @since 6.2.13
*/
public ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri, @Nullable Executor executor) {
this(httpClient, method, uri, executor, null);
}
/**
* Package private constructor for use until exchangeTimeout is removed.
*/
ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri, @Nullable Duration exchangeTimeout) {
ReactorClientHttpRequest(HttpClient httpClient, HttpMethod method, URI uri, @Nullable Executor executor, @Nullable Duration exchangeTimeout) {
this.httpClient = httpClient;
this.method = method;
this.uri = uri;
this.executor = (executor != null) ? executor : Schedulers.boundedElastic()::schedule;
this.exchangeTimeout = exchangeTimeout;
}
@@ -92,11 +107,7 @@ final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest
public ReactorClientHttpRequest(
HttpClient httpClient, URI uri, HttpMethod method,
@Nullable Duration exchangeTimeout, @Nullable Duration readTimeout) {
this.httpClient = httpClient;
this.method = method;
this.uri = uri;
this.exchangeTimeout = exchangeTimeout;
this(httpClient, method, uri, null, exchangeTimeout);
}
@@ -150,13 +161,10 @@ final class ReactorClientHttpRequest extends AbstractStreamingClientHttpRequest
return Mono.empty();
}
AtomicReference<Executor> executorRef = new AtomicReference<>();
return outbound
.withConnection(connection -> executorRef.set(connection.channel().eventLoop()))
.send(FlowAdapters.toPublisher(new OutputStreamPublisher<>(
os -> body.writeTo(StreamUtils.nonClosing(os)), new ByteBufMapper(outbound),
executorRef.getAndSet(null), null)));
this.executor, null)));
}
static IOException convertException(RuntimeException ex) {
@@ -19,11 +19,13 @@ package org.springframework.http.client;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.Executor;
import java.util.function.Function;
import io.netty.channel.ChannelOption;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.scheduler.Schedulers;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import reactor.netty.resources.LoopResources;
@@ -42,6 +44,7 @@ import org.springframework.util.Assert;
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Sebastien Deleuze
* @author Brian Clozel
* @since 6.2
*/
public class ReactorClientHttpRequestFactory implements ClientHttpRequestFactory, SmartLifecycle {
@@ -58,6 +61,9 @@ public class ReactorClientHttpRequestFactory implements ClientHttpRequestFactory
@Nullable
private final Function<HttpClient, HttpClient> mapper;
@Nullable
private Executor executor;
@Nullable
private Integer connectTimeout;
@@ -129,6 +135,16 @@ public class ReactorClientHttpRequestFactory implements ClientHttpRequestFactory
return client;
}
/**
* Set the {@code Executor} to use for performing blocking I/O operations.
* <p>If no executor is provided, the request will use an {@link Schedulers#boundedElastic() elastic scheduler}.
* @param executor the executor to use.
* @since 6.2.13
*/
public void setExecutor(Executor executor) {
Assert.notNull(executor, "Executor must not be null");
this.executor = executor;
}
/**
* Set the connect timeout value on the underlying client.
@@ -219,7 +235,7 @@ public class ReactorClientHttpRequestFactory implements ClientHttpRequestFactory
"Expected HttpClient or ResourceFactory and mapper");
client = createHttpClient(this.resourceFactory, this.mapper);
}
return new ReactorClientHttpRequest(client, httpMethod, uri, this.exchangeTimeout);
return new ReactorClientHttpRequest(client, httpMethod, uri, this.executor, this.exchangeTimeout);
}