Skip to content

Fix HTTP timeout enforcement and improve connection management#1795

Open
Copilot wants to merge 6 commits intomainfrom
copilot/fix-adyen-http-client-timeout
Open

Fix HTTP timeout enforcement and improve connection management#1795
Copilot wants to merge 6 commits intomainfrom
copilot/fix-adyen-http-client-timeout

Conversation

Copy link
Contributor

Copilot AI commented Mar 13, 2026

This pull request introduces comprehensive improvements to HTTP timeout configuration and connection pool management.

Root Cause

The AdyenHttpClient was only setting timeouts on RequestConfig (HTTP-level), but not on ConnectionConfig (socket-level). When a server accepted the TCP connection but stalled at the socket/TLS layer, the
HTTP-level responseTimeout alone was insufficient to terminate the request. Additionally, a new CloseableHttpClient was created and destroyed for every request, defeating connection pooling entirely.

This originated the issue reported in #1794

Changes

Timeout enforcement

• Added ConnectionConfig with socketTimeout and connectTimeout on the PoolingHttpClientConnectionManager, enforcing hard OS-level timeouts on socket reads and TCP/TLS handshakes.
• Moved connectTimeout from the deprecated RequestConfig.setConnectTimeout() to the proper ConnectionConfig.setConnectTimeout().
• Added defaultKeepAlive to the client-level RequestConfig for consistency.

Shared HTTP client

• Refactored AdyenHttpClient from creating a new CloseableHttpClient per request to lazily initializing and reusing a single shared instance (double-checked locking for thread safety).
Client.getHttpClient() now stores and reuses the same AdyenHttpClient instance.

Lifecycle management

ClientInterface now extends Closeable with a default no-op close() for backward compatibility with custom implementations.
Client implements Closeable, delegating to the underlying HTTP client for proper connection pool cleanup.

Documentation

• Improved README by adding "HTTP timeout configuration" section with default values and recommended best practises.

Fix #1794

Copilot AI changed the title [WIP] [Bug] Fix Adyen HTTP client timeout issue Fix: Set configured timeouts as default RequestConfig on HTTP client Mar 13, 2026
Copilot AI requested a review from gcatanese March 13, 2026 07:41
@gcatanese gcatanese added the Fix Indicates a bug fix label Mar 24, 2026
@gcatanese gcatanese marked this pull request as ready for review March 24, 2026 11:11
@gcatanese gcatanese requested review from a team as code owners March 24, 2026 11:11
@gcatanese gcatanese force-pushed the copilot/fix-adyen-http-client-timeout branch from 961f8b8 to 23f4c55 Compare March 24, 2026 11:11
@gcatanese gcatanese changed the title Fix: Set configured timeouts as default RequestConfig on HTTP client Fix HTTP timeout enforcement and improve connection management Mar 24, 2026
@gcatanese gcatanese removed their request for review March 24, 2026 12:43
@thomasc-adyen
Copy link
Contributor

Issues identified and proposed changes

1. close() races with getOrCreateHttpClient() — use-after-close is possible [P1]

The double-checked-locking in getOrCreateHttpClient reads sharedHttpClient outside the synchronized block first. If close() is called concurrently:

  1. Thread A passes the if (client != null) fast-path check — gets the reference.
  2. Thread B calls close(), acquires the lock, closes and nulls sharedHttpClient.
  3. Thread A proceeds to execute a request on the now-closed client → IOException.

Fix: After acquiring the lock in getOrCreateHttpClient, re-read sharedHttpClient from the field (not the local copy) before returning, so the returned reference is always the one currently stored under the lock. Also add @GuardedBy("lock") to the field for clarity.

// Current (flawed)
private CloseableHttpClient getOrCreateHttpClient(Config config) {
    CloseableHttpClient client = sharedHttpClient;   // unsynchronized read
    if (client != null) {
        return client;                               // may be closed already
    }
    synchronized (lock) {
        if (sharedHttpClient == null) {
            sharedHttpClient = createCloseableHttpClient(config);
        }
        return sharedHttpClient;
    }
}
// Proposed fix: always return from inside the lock
private CloseableHttpClient getOrCreateHttpClient(Config config) {
    if (sharedHttpClient != null) {          // volatile fast-path read
        return sharedHttpClient;
    }
    synchronized (lock) {
        if (sharedHttpClient == null) {
            sharedHttpClient = createCloseableHttpClient(config);
        }
        return sharedHttpClient;             // read under lock — safe vs close()
    }
}

This keeps the volatile fast-path for the no-contention case while eliminating the race window where a closed client reference can escape.


2. setProxy() after first request silently has no effect [P2]

proxy is read in createRequest() on every call, but the connection manager (which handles routing) is baked into the shared CloseableHttpClient at creation time. The RequestConfig proxy hint set per-request is applied at the routing level by Apache HC5, so proxy changes after initialization do take effect at the RequestConfig layer — but only if the proxy type is HTTP. If a user sets a SOCKS proxy the current code silently ignores it (proxy.address() instanceof InetSocketAddress check succeeds but HttpHost only describes HTTP proxies, not SOCKS).

No code change proposed for this PR — just worth a comment in setProxy() that SOCKS proxies are not supported.


3. socketTimeout on ConnectionConfig duplicates readTimeoutMillis — correct but undocumented tension [P2]

createHttpClientWithSocketFactory sets both:

  • ConnectionConfig.setSocketTimeout(readTimeoutMillis) — enforced at the socket/connection-manager level.
  • RequestConfig.setResponseTimeout(readTimeoutMillis) (default) and also per-request in createRequest.

In Apache HttpClient 5, responseTimeout and socketTimeout both control how long to wait for data, but socketTimeout is a per-connection lower bound enforced by the connection manager, while responseTimeout is checked by the response-reading machinery. Having both set to the same value is harmless and ensures the timeout fires regardless of code path. However, the per-request RequestConfig (set in createRequest) does not set socketTimeout — it can only set responseTimeout. So the effective socket-level timeout is always the one baked into the shared client at creation time, and later per-request overrides only apply to responseTimeout. This is fine functionally but inconsistent with the documentation that says "per-request config includes all timeouts".

No code change needed — but the Javadoc on createRequest should be corrected to note that socketTimeout is fixed at client-creation time.


Summary of changes

# File Change
1 AdyenHttpClient.java Fix getOrCreateHttpClient to eliminate the close-vs-read race by returning sharedHttpClient from inside the synchronized block (removing the early-return on the local copy)
2 AdyenHttpClient.java Add @GuardedBy("lock") annotation comment to sharedHttpClient field
3 AdyenHttpClient.java Add a clarifying comment to setProxy() that SOCKS proxies are not supported
4 AdyenHttpClient.java Correct the Javadoc on createRequest to note that socketTimeout is fixed at creation time

Only change #1 is a behavioral fix; the rest are documentation/annotation improvements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Fix Indicates a bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Adyen HTTP Client Timeout Issue

3 participants