Skip to content

Commit 6a1699f

Browse files
Support caller-provided HTTP client with caller-owned lifecycle
1 parent 3191d21 commit 6a1699f

6 files changed

Lines changed: 118 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99
### Added
10+
- Support for a caller-provided `CloseableHttpClient` via new `IpregistryClient` and `DefaultRequestHandler` constructors, allowing full control over connection pooling, proxying, TLS, and metrics. An injected client is not closed by the Ipregistry client (the caller retains ownership).
1011
- Typed error codes: `ApiException#getErrorCode()` (and `Error#getErrorCode()`) now return a strongly typed `ErrorCode` enum in addition to the raw string `code`, allowing callers to branch on error conditions without string matching. Unrecognized codes return `null` while the raw code remains available.
1112
- Automatic retries with exponential backoff for transient network errors and 5xx server responses, configurable via `IpregistryConfig` (`retryMaxAttempts`, `retryInterval`, `retryOnServerError`, `retryOnTooManyRequests`). Retries on 429 Too Many Requests are disabled by default and honor the `Retry-After` header when enabled.
1213
- `IpregistryClient` now implements `Closeable` to release the underlying HTTP connection pool.

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,24 @@ IpregistryConfig config =
157157
.build();
158158
```
159159

160+
## Custom HTTP client
161+
162+
By default, the client manages its own Apache HttpClient 5 instance (connection pool, timeouts, retries). To take full control — connection pool sizing, a proxy, custom TLS, or metrics — provide your own `CloseableHttpClient`:
163+
164+
```java
165+
CloseableHttpClient httpClient = HttpClients.custom()
166+
// ... your connection manager, proxy, TLS, retry strategy, etc.
167+
.build();
168+
169+
IpregistryConfig config =
170+
IpregistryConfig.builder().apiKey("YOUR_API_KEY").build();
171+
172+
IpregistryClient ipregistry =
173+
new IpregistryClient(config, NoCache.getInstance(), httpClient);
174+
```
175+
176+
When you supply your own client, you own its lifecycle: it is **not** closed when the Ipregistry client is closed, and the timeout/retry settings from `IpregistryConfig` are ignored in favor of your client's own configuration.
177+
160178
## Errors
161179

162180
All Ipregistry exceptions inherit the _IpregistryException_ class.

‎src/main/java/co/ipregistry/api/client/IpregistryClient.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import co.ipregistry.api.client.model.*;
2525
import co.ipregistry.api.client.options.IpregistryOption;
2626
import co.ipregistry.api.client.request.DefaultRequestHandler;
27+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
2728

2829
import java.io.Closeable;
2930
import java.io.IOException;
@@ -70,6 +71,20 @@ public IpregistryClient(final IpregistryConfig config, final IpregistryCache cac
7071
this(config, cache, new DefaultRequestHandler(config));
7172
}
7273

74+
/**
75+
* Creates a new client instance using a caller-provided {@link CloseableHttpClient}.
76+
* <p>
77+
* The caller keeps ownership of the supplied HTTP client: it is <em>not</em> closed when this
78+
* client is closed. Use this to fully control connection pooling, proxying, TLS, or metrics.
79+
*
80+
* @param config the configuration instance to use.
81+
* @param cache the cache instance to use.
82+
* @param httpClient the HTTP client to use for dispatching requests.
83+
*/
84+
public IpregistryClient(final IpregistryConfig config, final IpregistryCache cache, final CloseableHttpClient httpClient) {
85+
this(config, cache, new DefaultRequestHandler(config, httpClient));
86+
}
87+
7388
/**
7489
* Creates a new client instance using the specified {@code config}, {@code cache}, and {@code requestHandler} instances.
7590
*

‎src/main/java/co/ipregistry/api/client/request/DefaultRequestHandler.java‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public class DefaultRequestHandler implements IpregistryRequestHandler {
5858

5959
private final CloseableHttpClient httpClient;
6060

61+
private final boolean ownsHttpClient;
62+
6163
private final ObjectMapper objectMapper;
6264

6365

@@ -67,7 +69,7 @@ public class DefaultRequestHandler implements IpregistryRequestHandler {
6769
* @param config the configuration instance to use.
6870
*/
6971
public DefaultRequestHandler(final IpregistryConfig config) {
70-
this(config, new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false));
72+
this(config, defaultObjectMapper());
7173
}
7274

7375
/**
@@ -77,17 +79,61 @@ public DefaultRequestHandler(final IpregistryConfig config) {
7779
* @param objectMapper the object mapper instance used for unmarshalling responses.
7880
*/
7981
public DefaultRequestHandler(final IpregistryConfig config, final ObjectMapper objectMapper) {
82+
this(config, objectMapper, buildDefaultHttpClient(config), true);
83+
}
84+
85+
/**
86+
* Creates a {@code DefaultRequestHandler} using a caller-provided {@link CloseableHttpClient}.
87+
* <p>
88+
* The caller keeps ownership of the supplied client: it is <em>not</em> closed when this handler
89+
* (or the enclosing {@link co.ipregistry.api.client.IpregistryClient}) is closed. The client's own
90+
* request configuration and retry strategy are used; the timeout and retry settings from
91+
* {@code config} are ignored.
92+
*
93+
* @param config the configuration instance (used for the API key and base URL).
94+
* @param httpClient the HTTP client to use for dispatching requests.
95+
*/
96+
public DefaultRequestHandler(final IpregistryConfig config, final CloseableHttpClient httpClient) {
97+
this(config, defaultObjectMapper(), httpClient, false);
98+
}
99+
100+
/**
101+
* Creates a {@code DefaultRequestHandler} using a caller-provided {@link CloseableHttpClient} and
102+
* {@link ObjectMapper}.
103+
* <p>
104+
* The caller keeps ownership of the supplied client: it is <em>not</em> closed when this handler
105+
* (or the enclosing {@link co.ipregistry.api.client.IpregistryClient}) is closed.
106+
*
107+
* @param config the configuration instance (used for the API key and base URL).
108+
* @param objectMapper the object mapper instance used for unmarshalling responses.
109+
* @param httpClient the HTTP client to use for dispatching requests.
110+
*/
111+
public DefaultRequestHandler(final IpregistryConfig config, final ObjectMapper objectMapper,
112+
final CloseableHttpClient httpClient) {
113+
this(config, objectMapper, httpClient, false);
114+
}
115+
116+
private DefaultRequestHandler(final IpregistryConfig config, final ObjectMapper objectMapper,
117+
final CloseableHttpClient httpClient, final boolean ownsHttpClient) {
80118
this.config = config;
81119
this.objectMapper = objectMapper;
120+
this.httpClient = httpClient;
121+
this.ownsHttpClient = ownsHttpClient;
122+
}
123+
124+
private static ObjectMapper defaultObjectMapper() {
125+
return new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
126+
}
82127

128+
private static CloseableHttpClient buildDefaultHttpClient(final IpregistryConfig config) {
83129
final RequestConfig requestConfig =
84130
RequestConfig.custom()
85131
.setConnectionKeepAlive(TimeValue.ofMilliseconds(config.getConnectionKeepAlive()))
86132
.setConnectionRequestTimeout(config.getConnectionTimeout(), TimeUnit.MILLISECONDS)
87133
.setResponseTimeout(config.getSocketTimeout(), TimeUnit.MILLISECONDS)
88134
.build();
89135

90-
this.httpClient = HttpClients.custom()
136+
return HttpClients.custom()
91137
.setDefaultRequestConfig(requestConfig)
92138
.setRetryStrategy(new IpregistryRetryStrategy(
93139
config.getRetryMaxAttempts(),
@@ -257,7 +303,11 @@ String toJsonList(final Iterable<String> values) throws JsonProcessingException
257303

258304
@Override
259305
public void close() throws IOException {
260-
httpClient.close();
306+
// Only close the HTTP client if this handler created it; a caller-provided client
307+
// remains under the caller's ownership.
308+
if (ownsHttpClient) {
309+
httpClient.close();
310+
}
261311
}
262312

263313
}

‎src/test/java/co/ipregistry/api/client/IpregistryClientTest.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import co.ipregistry.api.client.request.DefaultRequestHandler;
4343
import org.junit.jupiter.api.Assertions;
4444
import org.junit.jupiter.api.Test;
45+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
4546
import org.mockito.Mockito;
4647

4748
import java.io.Closeable;
@@ -202,4 +203,19 @@ void testCloseClosesRequestHandler() throws Exception {
202203
Mockito.verify(requestHandler).close();
203204
}
204205

206+
@Test
207+
void testInjectedHttpClientNotClosedByClientClose() throws Exception {
208+
final IpregistryConfig config =
209+
IpregistryConfig.builder().apiKey("test").build();
210+
final CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class);
211+
212+
final IpregistryClient client =
213+
new IpregistryClient(config, NoCache.getInstance(), httpClient);
214+
215+
client.close();
216+
217+
// The caller owns the injected client; closing the Ipregistry client must not close it.
218+
Mockito.verify(httpClient, Mockito.never()).close();
219+
}
220+
205221
}

‎src/test/java/co/ipregistry/api/client/request/DefaultRequestHandlerTest.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
import co.ipregistry.api.client.IpregistryConfig;
2020
import co.ipregistry.api.client.options.IpregistryOption;
2121
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
2223
import org.junit.jupiter.api.Assertions;
2324
import org.junit.jupiter.api.Test;
25+
import org.mockito.Mockito;
2426

2527
import java.util.Arrays;
2628

@@ -60,4 +62,17 @@ void testToJsonListEscapesSpecialCharacters() throws Exception {
6062
Assertions.assertArrayEquals(values, parsed);
6163
}
6264

65+
@Test
66+
void testInjectedHttpClientIsNotClosed() throws Exception {
67+
final IpregistryConfig config =
68+
IpregistryConfig.builder().apiKey("test").build();
69+
final CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class);
70+
71+
final DefaultRequestHandler requestHandler = new DefaultRequestHandler(config, httpClient);
72+
requestHandler.close();
73+
74+
// A caller-provided client remains owned by the caller and must not be closed by the handler.
75+
Mockito.verify(httpClient, Mockito.never()).close();
76+
}
77+
6378
}

0 commit comments

Comments
 (0)