Overview JdkClientHttpRequestFactory currently defaults to using SimpleAsyncTaskExecutor when the provided java.net.http.HttpClient does not expose an executor. Since SimpleAsyncTaskExecutor creates a new platform thread per task, this can lead to thread exhaustion under high concurrency, especially for streaming requests or responses.
Unlike ReactorClientHttpRequestFactory (which introduced setExecutor in 6.2.13), JdkClientHttpRequestFactory provides no way to override the executor after instantiation without rebuilding the underlying HttpClient.
Problem Description When users create a standard shared HttpClient:
HttpClient sharedClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build(); // No explicit executor set
The constructor of JdkClientHttpRequestFactory executes:
this.executor = httpClient.executor().orElseGet(SimpleAsyncTaskExecutor::new);
Since HttpClient.executor() often returns Optional.empty() when using the default internal pool, Spring falls back to SimpleAsyncTaskExecutor.
This causes three significant issues: 1. Thread Explosion Risk: SimpleAsyncTaskExecutor creates an unbounded number of threads. During blocking I/O (e.g., large file uploads/downloads, streaming responses), this can lead to excessive thread creation and resource exhaustion.
-
No Customization Point: Users cannot assign a bounded thread pool, a VirtualThreadPerTaskExecutor, or a shared application-level executor without rebuilding the entire HttpClient.
-
Inconsistency Across Client Adapters: ReactorClientHttpRequestFactory provides an explicit setExecutor hook, while JdkClientHttpRequestFactory does not.
Proposed Solution Introduce a method such as:
public void setExecutor(Executor executor) {
Assert.notNull(executor, "Executor must not be null");
this.executor = executor;
}
This executor would be used for the blocking I/O adapter layer and would not interfere with the internal executor of the underlying JDK HttpClient.
Desired Usage
@Bean
public ClientHttpRequestFactory jdkClientHttpRequestFactory(HttpClient sharedClient) {
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(sharedClient);
// Provide a safe executor (e.g., Virtual Threads or Bounded Pool)
factory.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
// or: factory.setExecutor(new ThreadPoolTaskExecutor());
return factory;
}
Backwards Compatibility This change preserves current behavior:
-
If no executor is set, SimpleAsyncTaskExecutor continues to be the default fallback.
-
Existing applications experience no change in behavior.
Additional Suggestion A Javadoc note clarifying that the default fallback is SimpleAsyncTaskExecutor (unbounded thread creation) would help guide users toward safer production configurations.