Repository: langchain4j/langchain4j (2026-07-20T02:26:56Z)

Issue:
[BUG] ApacheHttpClient maps connectTimeout to the connection pool lease timeout
**Describe the bug**

`ApacheHttpClient` passes the SPI `connectTimeout` to `RequestConfig.Builder.setConnectionRequestTimeout`, which limits the wait to borrow a pooled connection, not connection establishment.

Establishment reads `RequestConfig.getConnectTimeout()` (`InternalExecRuntime`, `InternalHttpAsyncExecRuntime`), which is never set here. `PoolingHttpClientConnectionManager.connect()` then falls back to `ConnectionConfig.getConnectTimeout()`, whose default is 3 minutes.

`JdkHttpClient` and `OkHttpClient` map the same SPI value to the real connection timeout, so the three implementations behave differently.

**Log and Stack trace**

N/A

**To Reproduce**

```java
HttpClient client = ApacheHttpClient.builder()
        .connectTimeout(Duration.ofSeconds(15))
        .build();
// request to an unreachable host blocks for up to 3 minutes, not 15 seconds
```

**Expected behavior**

`connectTimeout` limits connection establishment, as it does for the JDK and OkHttp clients.

**Please complete the following information:**
- LangChain4j version: 1.18.0
- LLM(s) used: N/A
- Java version: 17
- Spring Boot version (if applicable): N/A

**Additional context**

#4926 proposes removing `connectTimeout`/`readTimeout` from `HttpClientBuilder` in 2.0. Is fixing this mapping in 1.x worth it, or would you rather wait for that change? Happy to open a PR either way.




Discussion:

Patch:
diff --git a/http-clients/langchain4j-http-client-apache/src/main/java/dev/langchain4j/http/client/apache/ApacheHttpClient.java b/http-clients/langchain4j-http-client-apache/src/main/java/dev/langchain4j/http/client/apache/ApacheHttpClient.java
index 4a911aea597..8b9cb8fb04b 100644
--- a/http-clients/langchain4j-http-client-apache/src/main/java/dev/langchain4j/http/client/apache/ApacheHttpClient.java
+++ b/http-clients/langchain4j-http-client-apache/src/main/java/dev/langchain4j/http/client/apache/ApacheHttpClient.java
@@ -17,6 +17,7 @@
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.net.SocketTimeoutException;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -58,8 +59,7 @@ public ApacheHttpClient(ApacheHttpClientBuilder builder) {
         RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
 
         if (builder.connectTimeout() != null) {
-            requestConfigBuilder.setConnectionRequestTimeout(
-                    Timeout.ofMilliseconds(builder.connectTimeout().toMillis()));
+            setConnectTimeout(requestConfigBuilder, builder.connectTimeout());
         }
         if (builder.readTimeout() != null) {
             requestConfigBuilder.setResponseTimeout(
@@ -74,6 +74,18 @@ public ApacheHttpClient(ApacheHttpClientBuilder builder) {
         this.asyncClient.start();
     }
 
+    /**
+     * {@code RequestConfig.Builder.setConnectTimeout} is deprecated in favour of
+     * {@code ConnectionConfig.Builder.setConnectTimeout}, but the latter can only be applied through a
+     * connection manager, which would override the one a user may have configured on the supplied client builders.
+     * The connect timeout from {@code RequestConfig} takes precedence over {@code ConnectionConfig} when set,
+     * and is read by both the sync and the async execution runtimes.
+     */
+    @SuppressWarnings("deprecation")
+    private static void setConnectTimeout(RequestConfig.Builder requestConfigBuilder, Duration connectTimeout) {
+        requestConfigBuilder.setConnectTimeout(Timeout.ofMilliseconds(connectTimeout.toMillis()));
+    }
+
     public static ApacheHttpClientBuilder builder() {
         return new ApacheHttpClientBuilder();
     }


Test patch:
diff --git a/http-clients/langchain4j-http-client-apache/src/test/java/dev/langchain4j/http/client/apache/ApacheHttpClientConnectTimeoutTest.java b/http-clients/langchain4j-http-client-apache/src/test/java/dev/langchain4j/http/client/apache/ApacheHttpClientConnectTimeoutTest.java
new file mode 100644
index 00000000000..1597d932883
--- /dev/null
+++ b/http-clients/langchain4j-http-client-apache/src/test/java/dev/langchain4j/http/client/apache/ApacheHttpClientConnectTimeoutTest.java
@@ -0,0 +1,128 @@
+package dev.langchain4j.http.client.apache;
+
+import static dev.langchain4j.http.client.HttpMethod.GET;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import dev.langchain4j.http.client.HttpClient;
+import dev.langchain4j.http.client.HttpRequest;
+import java.io.IOException;
+import java.time.Duration;
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.client5.http.io.ConnectionEndpoint;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.io.LeaseRequest;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that the connect timeout configured on the builder reaches the connection manager,
+ * which is what Apache HttpClient uses to limit connection establishment.
+ * The timeout expiring on a slow connection cannot be tested here: WireMock cannot simulate connection timeouts.
+ */
+class ApacheHttpClientConnectTimeoutTest {
+
+    private WireMockServer wireMockServer;
+
+    @BeforeEach
+    void beforeEach() {
+        wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort());
+        wireMockServer.start();
+        wireMockServer.stubFor(WireMock.get("/endpoint").willReturn(WireMock.ok()));
+    }
+
+    @AfterEach
+    void afterEach() {
+        if (wireMockServer != null) {
+            wireMockServer.stop();
+        }
+    }
+
+    @Test
+    void should_apply_connect_timeout_when_establishing_connection() {
+
+        ConnectTimeoutCapturingConnectionManager connectionManager = new ConnectTimeoutCapturingConnectionManager();
+
+        HttpClient client = ApacheHttpClient.builder()
+                .httpClientBuilder(HttpClients.custom().setConnectionManager(connectionManager))
+                .connectTimeout(Duration.ofSeconds(7))
+                .build();
+
+        client.execute(request());
+
+        assertThat(connectionManager.connectTimeout).isNotNull();
+        assertThat(connectionManager.connectTimeout.toMilliseconds()).isEqualTo(7000);
+    }
+
+    @Test
+    void should_not_apply_connect_timeout_when_not_configured() {
+
+        ConnectTimeoutCapturingConnectionManager connectionManager = new ConnectTimeoutCapturingConnectionManager();
+
+        HttpClient client = ApacheHttpClient.builder()
+                .httpClientBuilder(HttpClients.custom().setConnectionManager(connectionManager))
+                .build();
+
+        client.execute(request());
+
+        assertThat(connectionManager.connectTimeout).isNull();
+    }
+
+    private HttpRequest request() {
+        return HttpRequest.builder()
+                .method(GET)
+                .url(String.format("http://localhost:%s/endpoint", wireMockServer.port()))
+                .build();
+    }
+
+    /**
+     * Delegates to a real connection manager and records the connect timeout it is called with.
+     */
+    private static class ConnectTimeoutCapturingConnectionManager implements HttpClientConnectionManager {
+
+        private final PoolingHttpClientConnectionManager delegate = new PoolingHttpClientConnectionManager();
+
+        private TimeValue connectTimeout;
+
+        @Override
+        public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) {
+            return delegate.lease(id, route, requestTimeout, state);
+        }
+
+        @Override
+        public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) {
+            delegate.release(endpoint, newState, validDuration);
+        }
+
+        @Override
+        public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context)
+                throws IOException {
+            this.connectTimeout = connectTimeout;
+            delegate.connect(endpoint, connectTimeout, context);
+        }
+
+        @Override
+        public void upgrade(ConnectionEndpoint endpoint, HttpContext context) throws IOException {
+            delegate.upgrade(endpoint, context);
+        }
+
+        @Override
+        public void close(CloseMode closeMode) {
+            delegate.close(closeMode);
+        }
+
+        @Override
+        public void close() {
+            delegate.close();
+        }
+    }
+}