diff --git a/README.md b/README.md index 36f36892e..e2e5fc8c7 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,39 @@ System.setProperty("https.proxyUser", "squid"); System.setProperty("https.proxyPassword", "ward"); ~~~~ +### HTTP timeout configuration + +The library provides configurable timeout settings on the `Config` object. These timeouts are applied consistently across all API calls (Checkout, Payments, Recurring, Terminal, etc.). + +| Config Property | Default | Description | +|------------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `connectionTimeoutMillis` | 60000 ms | Maximum time to wait for a TCP connection (and TLS handshake) to be established with the server. If the server is unreachable or slow to accept connections, this timeout fires. | +| `readTimeoutMillis` | 60000 ms | Maximum time to wait for data on an already established connection (socket read timeout). This is the hard upper bound on how long any single API call can take once connected. | +| `connectionRequestTimeoutMillis` | 60000 ms | Maximum time to wait to lease a connection from the internal connection pool. Relevant under high concurrency when the pool is saturated. | +| `defaultKeepAliveMillis` | 60000 ms | Duration to keep idle connections alive for reuse. | + +**Best practices:** + +- Always set explicit timeouts for production environments. The 60-second defaults may be too high for latency-sensitive services. +- Set `readTimeoutMillis` to match your maximum acceptable API response time. For example, if your SLA requires failing fast on slow downstream calls, use a lower value (e.g. 10-15 seconds). +- Keep `connectionTimeoutMillis` relatively low (e.g. 5-15 seconds) since a healthy server should accept connections quickly. + +~~~~ java +// Example: Configure timeouts via Config object +Config config = new Config() + .environment(Environment.LIVE) + .liveEndpointUrlPrefix("myCompany") + .apiKey("YOUR_API_KEY") + .connectionTimeoutMillis(10000) // 10 sec to establish connection + .readTimeoutMillis(15000) // 15 sec max to receive a response + .connectionRequestTimeoutMillis(5000); // 5 sec to acquire a pooled connection + +Client client = new Client(config); + +// Or use the convenience method on the Client object +client.setTimeouts(10000, 15000); +~~~~ + ### Client certificate authentication ~~~~ java diff --git a/src/main/java/com/adyen/Client.java b/src/main/java/com/adyen/Client.java index 6b1d17ac5..e25800464 100644 --- a/src/main/java/com/adyen/Client.java +++ b/src/main/java/com/adyen/Client.java @@ -4,9 +4,11 @@ import com.adyen.enums.Region; import com.adyen.httpclient.AdyenHttpClient; import com.adyen.httpclient.ClientInterface; +import java.io.Closeable; +import java.io.IOException; import javax.net.ssl.SSLContext; -public class Client { +public class Client implements Closeable { private ClientInterface httpClient; private Config config; public static final String LIB_NAME = "adyen-java-api-library"; @@ -157,13 +159,23 @@ public String toString() { } public ClientInterface getHttpClient() { - return this.httpClient == null ? new AdyenHttpClient() : this.httpClient; + if (this.httpClient == null) { + this.httpClient = new AdyenHttpClient(); + } + return this.httpClient; } public void setHttpClient(ClientInterface httpClient) { this.httpClient = httpClient; } + @Override + public void close() throws IOException { + if (this.httpClient != null) { + this.httpClient.close(); + } + } + public Config getConfig() { return config; } diff --git a/src/main/java/com/adyen/Config.java b/src/main/java/com/adyen/Config.java index 08a8ca2aa..693cc4452 100644 --- a/src/main/java/com/adyen/Config.java +++ b/src/main/java/com/adyen/Config.java @@ -5,33 +5,94 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; +/** + * Configuration for the Adyen API client (authentication credentials, environment settings, HTTP + * timeout values, and Terminal API configuration, etc..). + * + *

Supports a fluent builder pattern for concise configuration: + * + *

{@code
+ * Config config = new Config()
+ *     .environment(Environment.LIVE)
+ *     .liveEndpointUrlPrefix("myCompany")
+ *     .apiKey("YOUR_API_KEY")
+ *     .connectionTimeoutMillis(10000)
+ *     .readTimeoutMillis(15000);
+ * }
+ * + *

Important: Consider setting Timeout and SSL configuration. The underlying HTTP client + * is created lazily on the first request and reuses the configuration captured at that point. + * Changes made after the first request will not take effect. + * + * @see Client + * @see com.adyen.httpclient.AdyenHttpClient + */ public class Config { - // API key authentication + + /** API key for authentication. */ protected String apiKey; - // Basic authentication + + /** Username for HTTP basic authentication. */ protected String username; + + /** Password for HTTP basic authentication. */ protected String password; - // Environment: Test or Live + + /** The target environment (Test or Live). */ protected Environment environment; - // Application name: used as HTTP client User-Agent + /** Application name included in the User-Agent header. */ protected String applicationName; - // HTTP Client options - protected int connectionTimeoutMillis = 60 * 1000; // default 60 sec - protected int readTimeoutMillis = 60 * 1000; // default 60 sec - protected int connectionRequestTimeoutMillis = 60 * 1000; // default 60 sec - protected int defaultKeepAliveMillis = 60 * 1000; // default 60 sec + /** + * Maximum time in milliseconds to wait for a TCP connection (and TLS handshake) to be + * established. Default: 60000 (60 seconds). + */ + protected int connectionTimeoutMillis = 60 * 1000; + + /** + * Maximum time in milliseconds to wait for data on an already established connection (socket read + * timeout). This is the hard upper bound on how long any single API call can take once connected. + * Default: 60000 (60 seconds). + */ + protected int readTimeoutMillis = 60 * 1000; + + /** + * Maximum time in milliseconds to wait to lease a connection from the internal connection pool. + * Relevant under high concurrency when the pool is saturated. Default: 60000 (60 seconds). + */ + protected int connectionRequestTimeoutMillis = 60 * 1000; + + /** + * Duration in milliseconds to keep idle connections alive for reuse. Default: 60000 (60 seconds). + */ + protected int defaultKeepAliveMillis = 60 * 1000; + + /** + * Whether HTTP requests should automatically attempt to upgrade to a newer protocol version. If + * null, the Apache HttpClient default is used. + */ protected Boolean protocolUpgradeEnabled; - // Terminal API configuration + /** The Cloud Terminal API endpoint URL. */ protected String terminalApiCloudEndpoint; + + /** The Local Terminal API endpoint URL */ protected String terminalApiLocalEndpoint; + + /** The unique live URL prefix for live environment endpoints. */ protected String liveEndpointUrlPrefix; + + /** The region for the Terminal API Cloud endpoint. */ protected Region terminalApiRegion; + + /** The SSL context for client certificate authentication or custom trust stores. */ protected SSLContext sslContext; + + /** The hostname verifier for Terminal Local API connections. */ protected HostnameVerifier hostnameVerifier; + /** Creates a new Config with default values. */ public Config() { // do nothing } @@ -140,53 +201,123 @@ public Config terminalApiRegion(Region terminalApiRegion) { return this; } + /** + * Returns the connection timeout in milliseconds. + * + * @return the maximum time to wait for a TCP connection to be established + */ public int getConnectionTimeoutMillis() { return connectionTimeoutMillis; } + /** + * Sets the maximum time in milliseconds to wait for a TCP connection (and TLS handshake) to be + * established. A lower value (e.g. 5000-15000) is recommended for production to fail fast when + * the server is unreachable. + * + * @param connectionTimeoutMillis the connection timeout in milliseconds + */ public void setConnectionTimeoutMillis(int connectionTimeoutMillis) { this.connectionTimeoutMillis = connectionTimeoutMillis; } + /** + * Fluent setter for {@link #setConnectionTimeoutMillis(int)}. + * + * @param connectionTimeoutMillis the connection timeout in milliseconds + * @return this Config instance + */ public Config connectionTimeoutMillis(int connectionTimeoutMillis) { this.connectionTimeoutMillis = connectionTimeoutMillis; return this; } + /** + * Returns the read timeout in milliseconds. + * + * @return the maximum time to wait for response data on an established connection + */ public int getReadTimeoutMillis() { return readTimeoutMillis; } + /** + * Sets the maximum time in milliseconds to wait for data on an already established connection. + * This acts as both the HTTP response timeout and the socket-level read timeout, providing a hard + * upper bound on API call duration. Set this to match your maximum acceptable response time. + * + * @param readTimeoutMillis the read timeout in milliseconds + */ public void setReadTimeoutMillis(int readTimeoutMillis) { this.readTimeoutMillis = readTimeoutMillis; } + /** + * Fluent setter for {@link #setReadTimeoutMillis(int)}. + * + * @param readTimeoutMillis the read timeout in milliseconds + * @return this Config instance + */ public Config readTimeoutMillis(int readTimeoutMillis) { this.readTimeoutMillis = readTimeoutMillis; return this; } + /** + * Returns the default keep-alive duration in milliseconds. + * + * @return the duration to keep idle connections alive for reuse + */ public int getDefaultKeepAliveMillis() { return defaultKeepAliveMillis; } + /** + * Sets the duration in milliseconds to keep idle connections alive for reuse. + * + * @param defaultKeepAliveMillis the keep-alive duration in milliseconds + */ public void setDefaultKeepAliveMillis(int defaultKeepAliveMillis) { this.defaultKeepAliveMillis = defaultKeepAliveMillis; } + /** + * Fluent setter for {@link #setDefaultKeepAliveMillis(int)}. + * + * @param defaultKeepAliveMillis the keep-alive duration in milliseconds + * @return this Config instance + */ public Config defaultKeepAliveMillis(int defaultKeepAliveMillis) { this.defaultKeepAliveMillis = defaultKeepAliveMillis; return this; } + /** + * Returns the connection request timeout in milliseconds. + * + * @return the maximum time to wait to lease a connection from the pool + */ public int getConnectionRequestTimeoutMillis() { return connectionRequestTimeoutMillis; } + /** + * Sets the maximum time in milliseconds to wait to lease a connection from the internal + * connection pool. This timeout is relevant under high concurrency when all pooled connections + * are in use. + * + * @param connectionRequestTimeoutMillis the connection request timeout in milliseconds + */ public void setConnectionRequestTimeoutMillis(int connectionRequestTimeoutMillis) { this.connectionRequestTimeoutMillis = connectionRequestTimeoutMillis; } + /** + * Fluent setter for {@link #setConnectionRequestTimeoutMillis(int)}. + * + * @param connectionRequestTimeoutMillis the connection request timeout in milliseconds + * @return this Config instance + */ public Config connectionRequestTimeoutMillis(int connectionRequestTimeoutMillis) { this.connectionRequestTimeoutMillis = connectionRequestTimeoutMillis; return this; diff --git a/src/main/java/com/adyen/httpclient/AdyenHttpClient.java b/src/main/java/com/adyen/httpclient/AdyenHttpClient.java index ab08231a7..301feb2d8 100644 --- a/src/main/java/com/adyen/httpclient/AdyenHttpClient.java +++ b/src/main/java/com/adyen/httpclient/AdyenHttpClient.java @@ -53,6 +53,7 @@ import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpUriRequest; import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -63,20 +64,92 @@ import org.apache.hc.core5.net.URIBuilder; import org.apache.hc.core5.ssl.SSLContexts; -/** HTTP client implementation to invoke the Adyen APIs. Built on top of org.apache.hc.client5 */ +/** + * HTTP client implementation to invoke the Adyen APIs. + * + *

This client maintains a shared {@link CloseableHttpClient} instance that is lazily initialized + * on the first request and reused for all subsequent requests. This enables connection pooling, TCP + * and TLS session reuse, and natural backpressure under high concurrency. + * + *

Timeout values from {@link Config} are applied at two levels: + * + *

+ * + *

The shared HTTP client is created from the {@link Config} provided on the first request. + * Subsequent changes to {@link Config} timeout values will not affect the already-created client. + * Configuration must be finalized before the first API call. * + * + * @see ClientInterface + * @see Config + */ public class AdyenHttpClient implements ClientInterface { private static final String CHARSET = "UTF-8"; private Proxy proxy; - + private volatile CloseableHttpClient sharedHttpClient; + private final Object lock = new Object(); + + /** + * Returns the proxy configured for this HTTP client. + * + * @return the proxy, or null if no proxy is set + */ public Proxy getProxy() { return proxy; } + /** + * Sets a proxy to use for all HTTP requests made by this client. + * + * @param proxy the proxy (e.g. {@code new Proxy(Proxy.Type.HTTP, new InetSocketAddress("host", + * port))}) + */ public void setProxy(Proxy proxy) { + // Note: only HTTP proxies are supported; SOCKS proxies are silently ignored. this.proxy = proxy; } + /** + * Closes the shared HTTP client and releases all pooled connections. This method is idempotent + * and thread-safe; calling it multiple times has no additional effect. + * + * @throws IOException if an I/O error occurs while closing the underlying client + */ + @Override + public void close() throws IOException { + synchronized (lock) { + if (sharedHttpClient != null) { + sharedHttpClient.close(); + sharedHttpClient = null; + } + } + } + + /** + * Returns the shared {@link CloseableHttpClient}, creating it on first access using the provided + * {@link Config}. Uses double-checked locking for thread safety. + * + * @param config the configuration used to build the HTTP client on first invocation + * @return the shared HTTP client instance + */ + private CloseableHttpClient getOrCreateHttpClient(Config config) { + CloseableHttpClient client = sharedHttpClient; + if (client != null) { + return client; + } + synchronized (lock) { + if (sharedHttpClient == null) { + sharedHttpClient = createCloseableHttpClient(config); + } + return sharedHttpClient; + } + } + @Override public String request(String endpoint, String requestBody, Config config) throws IOException, HTTPClientException { @@ -124,22 +197,35 @@ public String request( ApiConstants.HttpMethod httpMethod, Map params) throws IOException, HTTPClientException { - try (CloseableHttpClient httpclient = createCloseableHttpClient(config)) { - HttpUriRequestBase httpRequest = - createRequest( - endpoint, requestBody, config, isApiKeyRequired, requestOptions, httpMethod, params); + CloseableHttpClient httpclient = getOrCreateHttpClient(config); + HttpUriRequestBase httpRequest = + createRequest( + endpoint, requestBody, config, isApiKeyRequired, requestOptions, httpMethod, params); - // Execute request with a custom response handler - AdyenResponse response = httpclient.execute(httpRequest, new AdyenResponseHandler()); + // Execute request with a custom response handler + AdyenResponse response = httpclient.execute(httpRequest, new AdyenResponseHandler()); - if (response.getStatus() < 200 || response.getStatus() >= 300) { - throw new HTTPClientException( - response.getStatus(), "HTTP Exception", response.getHeaders(), response.getBody()); - } - return response.getBody(); + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new HTTPClientException( + response.getStatus(), "HTTP Exception", response.getHeaders(), response.getBody()); } + return response.getBody(); } + /** + * Builds an {@link HttpUriRequestBase} with the appropriate HTTP method, headers, authentication, + * and per-request timeout configuration from {@link Config}. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body (may be null for GET/DELETE) + * @param config the client configuration containing timeout and authentication settings + * @param isApiKeyRequired whether API key authentication is mandatory + * @param requestOptions additional request options (idempotency key, custom headers) + * @param httpMethod the HTTP method (GET, POST, PATCH, DELETE) + * @param params query string parameters appended to the URL + * @return the fully configured HTTP request + * @throws HTTPClientException if the endpoint URI is invalid + */ HttpUriRequestBase createRequest( String endpoint, String requestBody, @@ -249,28 +335,64 @@ private URI createUri(String endpoint, Map params) throws HTTPCl } } - private CloseableHttpClient createCloseableHttpClient(Config config) { + /** + * Creates a new {@link CloseableHttpClient} configured with SSL, connection-level timeouts, and + * request-level defaults from the given {@link Config}. This method is package-private to allow + * testing. + * + * @param config the configuration used to set up SSL context, hostname verifier, and timeouts + * @return a configured HTTP client instance + */ + CloseableHttpClient createCloseableHttpClient(Config config) { SSLContext sslContext = config.getSSLContext(); if (sslContext == null) { sslContext = SSLContexts.createDefault(); } HostnameVerifier hostnameVerifier = config.getHostnameVerifier(); return createHttpClientWithSocketFactory( - new SSLConnectionSocketFactory(sslContext, hostnameVerifier)); + new SSLConnectionSocketFactory(sslContext, hostnameVerifier), config); } + /** + * Creates a {@link CloseableHttpClient} with the given SSL socket factory and timeout + * configuration. Sets up both connection-level timeouts ({@link ConnectionConfig} with {@code + * connectTimeout} and {@code socketTimeout}) and request-level defaults ({@link RequestConfig} + * with {@code responseTimeout}, {@code connectionRequestTimeout}, and {@code defaultKeepAlive}). + * + * @param socketFactory the SSL socket factory for HTTPS connections + * @param config the configuration containing timeout values + * @return a configured HTTP client instance + */ private CloseableHttpClient createHttpClientWithSocketFactory( - SSLConnectionSocketFactory socketFactory) { + SSLConnectionSocketFactory socketFactory, Config config) { + RequestConfig defaultRequestConfig = + RequestConfig.custom() + .setResponseTimeout(config.getReadTimeoutMillis(), TimeUnit.MILLISECONDS) + .setConnectionRequestTimeout( + config.getConnectionRequestTimeoutMillis(), TimeUnit.MILLISECONDS) + .setDefaultKeepAlive(config.getDefaultKeepAliveMillis(), TimeUnit.MILLISECONDS) + .build(); + ConnectionConfig connectionConfig = + ConnectionConfig.custom() + .setConnectTimeout(config.getConnectionTimeoutMillis(), TimeUnit.MILLISECONDS) + // socketTimeout acts as an OS-level safety net for stalled reads; + // responseTimeout (in RequestConfig) is the HTTP-level equivalent. + // Both are set to readTimeoutMillis so the request is bounded regardless of which layer + // fires first. + .setSocketTimeout(config.getReadTimeoutMillis(), TimeUnit.MILLISECONDS) + .build(); return HttpClients.custom() .setConnectionManager( PoolingHttpClientConnectionManagerBuilder.create() .setSSLSocketFactory(socketFactory) + .setDefaultConnectionConfig(connectionConfig) .build()) + .setDefaultRequestConfig(defaultRequestConfig) .setRedirectStrategy(new AdyenCustomRedirectStrategy()) .build(); } - /** Sets content type */ + /** Sets authentication headers (API key or basic auth) based on the configuration. */ private void setAuthentication( HttpUriRequest httpUriRequest, boolean isApiKeyRequired, Config config) { String apiKey = config.getApiKey(); @@ -282,12 +404,12 @@ private void setAuthentication( } } - /** Sets content type */ + /** Sets the Content-Type header on the request. */ private void setContentType(HttpUriRequest httpUriRequest, String contentType) { httpUriRequest.addHeader(CONTENT_TYPE, contentType); } - /** Sets api key */ + /** Sets the X-API-Key header on the request. */ private void setApiKey(HttpUriRequest httpUriRequest, String apiKey) { if (apiKey != null && !apiKey.isEmpty()) { httpUriRequest.addHeader(API_KEY, apiKey); diff --git a/src/main/java/com/adyen/httpclient/ClientInterface.java b/src/main/java/com/adyen/httpclient/ClientInterface.java index 3bf07ddf9..5ff43e387 100644 --- a/src/main/java/com/adyen/httpclient/ClientInterface.java +++ b/src/main/java/com/adyen/httpclient/ClientInterface.java @@ -23,17 +23,65 @@ import com.adyen.Config; import com.adyen.constants.ApiConstants; import com.adyen.model.RequestOptions; +import java.io.Closeable; import java.io.IOException; import java.util.Map; -public interface ClientInterface { +/** + * Interface for HTTP client implementations used to make Adyen API requests. All API services + * delegate HTTP communication to an implementation of this interface. + * + *

Implements {@link Closeable} to allow releasing underlying resources (e.g. connection pools). + * A default no-op {@link #close()} is provided for backward compatibility with existing custom + * implementations. + * + * @see com.adyen.httpclient.AdyenHttpClient + */ +public interface ClientInterface extends Closeable { + + /** Default no-op close for backward compatibility with custom implementations. */ + @Override + default void close() throws IOException {} + /** + * Sends an HTTP POST request to the given endpoint. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body + * @param config the client configuration containing authentication and timeout settings + * @return the JSON response body + * @throws IOException if a network error occurs + * @throws HTTPClientException if the server returns a non-2xx status code + */ String request(String endpoint, String requestBody, Config config) throws IOException, HTTPClientException; + /** + * Sends an HTTP POST request with optional API key authentication. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body + * @param config the client configuration + * @param isApiKeyRequired whether API key authentication is mandatory for this request + * @return the JSON response body + * @throws IOException if a network error occurs + * @throws HTTPClientException if the server returns a non-2xx status code + */ String request(String endpoint, String requestBody, Config config, boolean isApiKeyRequired) throws IOException, HTTPClientException; + /** + * Sends an HTTP POST request with optional API key authentication and request options. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body + * @param config the client configuration + * @param isApiKeyRequired whether API key authentication is mandatory + * @param requestOptions additional request options (idempotency key, custom headers) + * @return the JSON response body + * @throws IOException if a network error occurs + * @throws HTTPClientException if the server returns a non-2xx status code + */ String request( String endpoint, String requestBody, @@ -42,6 +90,19 @@ String request( RequestOptions requestOptions) throws IOException, HTTPClientException; + /** + * Sends an HTTP request with the specified method, authentication, and request options. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body (may be null for GET/DELETE) + * @param config the client configuration + * @param isApiKeyRequired whether API key authentication is mandatory + * @param requestOptions additional request options (idempotency key, custom headers) + * @param httpMethod the HTTP method (GET, POST, PATCH, DELETE) + * @return the JSON response body + * @throws IOException if a network error occurs + * @throws HTTPClientException if the server returns a non-2xx status code + */ String request( String endpoint, String requestBody, @@ -51,6 +112,21 @@ String request( ApiConstants.HttpMethod httpMethod) throws IOException, HTTPClientException; + /** + * Sends an HTTP request with the specified method, authentication, request options, and query + * string parameters. This is the most complete overload used by all API service classes. + * + * @param endpoint the full URL of the API endpoint + * @param requestBody the JSON request body (may be null for GET/DELETE) + * @param config the client configuration + * @param isApiKeyRequired whether API key authentication is mandatory + * @param requestOptions additional request options (idempotency key, custom headers) + * @param httpMethod the HTTP method (GET, POST, PATCH, DELETE) + * @param params query string parameters appended to the URL + * @return the JSON response body + * @throws IOException if a network error occurs + * @throws HTTPClientException if the server returns a non-2xx status code + */ String request( String endpoint, String requestBody, diff --git a/src/test/java/com/adyen/httpclient/ClientTest.java b/src/test/java/com/adyen/httpclient/ClientTest.java index 7107bd594..66d4449c9 100644 --- a/src/test/java/com/adyen/httpclient/ClientTest.java +++ b/src/test/java/com/adyen/httpclient/ClientTest.java @@ -14,6 +14,9 @@ import java.util.stream.Stream; import javax.net.ssl.SSLContext; import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.Configurable; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.core5.http.Header; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -150,6 +153,66 @@ public void testRequestOptionsAddAdditionalServiceHeader() { assertEquals(3, requestOptions.getAdditionalServiceHeaders().size()); } + @Test + public void testDefaultRequestConfigTimeoutsAppliedToHttpClient() throws Exception { + AdyenHttpClient adyenHttpClient = new AdyenHttpClient(); + Config config = new Config(); + config.setConnectionTimeoutMillis(15000); + config.setReadTimeoutMillis(15000); + config.setConnectionRequestTimeoutMillis(15000); + + try (CloseableHttpClient httpClient = adyenHttpClient.createCloseableHttpClient(config)) { + assertInstanceOf(Configurable.class, httpClient); + RequestConfig defaultConfig = ((Configurable) httpClient).getConfig(); + assertNotNull(defaultConfig); + assertEquals(15000, defaultConfig.getResponseTimeout().toMilliseconds()); + assertEquals(15000, defaultConfig.getConnectionRequestTimeout().toMilliseconds()); + } + } + + @Test + public void testDefaultRequestConfigTimeoutsWithConfigDefaults() throws Exception { + AdyenHttpClient adyenHttpClient = new AdyenHttpClient(); + Config config = new Config(); + + try (CloseableHttpClient httpClient = adyenHttpClient.createCloseableHttpClient(config)) { + assertInstanceOf(Configurable.class, httpClient); + RequestConfig defaultConfig = ((Configurable) httpClient).getConfig(); + assertNotNull(defaultConfig); + assertEquals( + config.getReadTimeoutMillis(), defaultConfig.getResponseTimeout().toMilliseconds()); + assertEquals( + config.getConnectionRequestTimeoutMillis(), + defaultConfig.getConnectionRequestTimeout().toMilliseconds()); + } + } + + @Test + public void testPerRequestConfigIncludesAllTimeouts() throws Exception { + AdyenHttpClient adyenHttpClient = new AdyenHttpClient(); + Config config = new Config(); + config.setConnectionTimeoutMillis(10000); + config.setReadTimeoutMillis(20000); + config.setConnectionRequestTimeoutMillis(30000); + config.setDefaultKeepAliveMillis(40000); + + HttpUriRequestBase request = + adyenHttpClient.createRequest( + "https://checkout-test.adyen.com/v68/payments", + "{}", + config, + true, + null, + ApiConstants.HttpMethod.POST, + Map.of()); + + RequestConfig requestConfig = request.getConfig(); + assertNotNull(requestConfig); + assertEquals(10000, requestConfig.getConnectTimeout().toMilliseconds()); + assertEquals(20000, requestConfig.getResponseTimeout().toMilliseconds()); + assertEquals(30000, requestConfig.getConnectionRequestTimeout().toMilliseconds()); + } + @Test public void testUserAgentWithApplicationName() throws Exception { @@ -222,4 +285,20 @@ public void testRequestWithHttpHeaders() throws Exception { assertNotNull(wwwAuthenticate); assertEquals("www-authenticate-header", wwwAuthenticate.getValue()); } + + @Test + public void testGetHttpClientReturnsSameInstance() { + Client client = new Client("apiKey", Environment.TEST); + ClientInterface first = client.getHttpClient(); + ClientInterface second = client.getHttpClient(); + assertSame(first, second); + } + + @Test + public void testClientCloseIsIdempotent() throws Exception { + Client client = new Client("apiKey", Environment.TEST); + client.getHttpClient(); + client.close(); + client.close(); + } }