Skip to content

Commit 89cdc92

Browse files
Add CompletableFuture async API backed by a virtual-thread executor
1 parent 6a1699f commit 89cdc92

5 files changed

Lines changed: 216 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+
- Asynchronous API: `lookupAsync`/`parseAsync` methods on `IpregistryClient` return a `CompletableFuture` and are backed by a virtual-thread-per-task executor by default. The executor is configurable via `IpregistryConfig#executor` (a caller-provided executor is not shut down by the client).
1011
- 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).
1112
- 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.
1213
- 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.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,35 @@ IpregistryClient ipregistry =
175175

176176
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.
177177

178+
## Asynchronous API
179+
180+
Every lookup and parse method has an asynchronous variant returning a `CompletableFuture`, suitable for composing non-blocking pipelines:
181+
182+
```java
183+
IpregistryClient ipregistry = new IpregistryClient("YOUR_API_KEY");
184+
185+
ipregistry.lookupAsync("8.8.8.8")
186+
.thenAccept(info -> System.out.println(info.getLocation().getCountry().getName()))
187+
.exceptionally(error -> {
188+
error.getCause().printStackTrace(); // ApiException or ClientException
189+
return null;
190+
});
191+
```
192+
193+
The futures are backed by a virtual-thread-per-task executor (Java 21+), so thousands of concurrent lookups are cheap. A failed request completes the future exceptionally with the same `ApiException`/`ClientException` thrown by the synchronous API (wrapped in a `CompletionException`, so unwrap via `getCause()`).
194+
195+
To use your own executor, set it on the configuration. You then own its lifecycle: it is not shut down when the client is closed.
196+
197+
```java
198+
IpregistryConfig config =
199+
IpregistryConfig.builder()
200+
.apiKey("YOUR_API_KEY")
201+
.executor(myExecutorService)
202+
.build();
203+
```
204+
205+
Reactive users can bridge these futures directly, for example with `Mono.fromFuture(...)` (Reactor) or `Single.fromCompletionStage(...)` (RxJava).
206+
178207
## Errors
179208

180209
All Ipregistry exceptions inherit the _IpregistryException_ class.

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

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
import java.net.InetAddress;
3232
import java.util.ArrayList;
3333
import java.util.List;
34+
import java.util.concurrent.CompletableFuture;
35+
import java.util.concurrent.CompletionException;
36+
import java.util.concurrent.ExecutorService;
37+
import java.util.concurrent.Executors;
3438

3539

3640
/**
@@ -42,6 +46,10 @@ public class IpregistryClient implements Closeable {
4246

4347
private final DefaultRequestHandler requestHandler;
4448

49+
private final ExecutorService executor;
50+
51+
private final boolean ownsExecutor;
52+
4553

4654
/**
4755
* Creates a new client instance using the specified {@code apiKey}.
@@ -95,6 +103,14 @@ public IpregistryClient(final IpregistryConfig config, final IpregistryCache cac
95103
public IpregistryClient(final IpregistryConfig config, final IpregistryCache cache, final DefaultRequestHandler requestHandler) {
96104
this.cache = cache;
97105
this.requestHandler = requestHandler;
106+
107+
if (config.getExecutor() != null) {
108+
this.executor = config.getExecutor();
109+
this.ownsExecutor = false;
110+
} else {
111+
this.executor = Executors.newVirtualThreadPerTaskExecutor();
112+
this.ownsExecutor = true;
113+
}
98114
}
99115

100116
/**
@@ -246,8 +262,89 @@ public UserAgentList parse(final String... userAgents) throws ApiException, Clie
246262
}
247263

248264
/**
249-
* Releases the resources held by this client, notably the underlying HTTP connection pool.
250-
* Once closed, the client must no longer be used.
265+
* Asynchronous variant of {@link #lookup(IpregistryOption...)}.
266+
*
267+
* @param options the options to use when dispatching the request.
268+
* @return a future completing with the data found for the origin IP address, or completing
269+
* exceptionally with an {@link ApiException} or {@link ClientException}.
270+
*/
271+
public CompletableFuture<RequesterIpInfo> lookupAsync(final IpregistryOption... options) {
272+
return supplyAsync(() -> lookup(options));
273+
}
274+
275+
/**
276+
* Asynchronous variant of {@link #lookup(String, IpregistryOption...)}.
277+
*
278+
* @param ip the IP address to lookup.
279+
* @param options the options to use when dispatching the request.
280+
* @return a future completing with the data found for the IP address, or completing
281+
* exceptionally with an {@link ApiException} or {@link ClientException}.
282+
*/
283+
public CompletableFuture<IpInfo> lookupAsync(final String ip, final IpregistryOption... options) {
284+
return supplyAsync(() -> lookup(ip, options));
285+
}
286+
287+
/**
288+
* Asynchronous variant of {@link #lookup(InetAddress, IpregistryOption...)}.
289+
*
290+
* @param ip the IP address to lookup.
291+
* @param options the options to use when dispatching the request.
292+
* @return a future completing with the data found for the IP address, or completing
293+
* exceptionally with an {@link ApiException} or {@link ClientException}.
294+
*/
295+
public CompletableFuture<IpInfo> lookupAsync(final InetAddress ip, final IpregistryOption... options) {
296+
return supplyAsync(() -> lookup(ip, options));
297+
}
298+
299+
/**
300+
* Asynchronous variant of {@link #lookup(List, IpregistryOption...)}.
301+
*
302+
* @param ips the IP addresses to lookup.
303+
* @param options the options to use when dispatching the request.
304+
* @return a future completing with the data found for the IP addresses, or completing
305+
* exceptionally with an {@link ApiException} or {@link ClientException}.
306+
*/
307+
public CompletableFuture<IpInfoList> lookupAsync(final List<String> ips, final IpregistryOption... options) {
308+
return supplyAsync(() -> lookup(ips, options));
309+
}
310+
311+
/**
312+
* Asynchronous variant of {@link #parse(String...)}.
313+
*
314+
* @param userAgents the raw user-agent values.
315+
* @return a future completing with the parsed user-agent data, or completing exceptionally with
316+
* an {@link ApiException} or {@link ClientException}.
317+
*/
318+
public CompletableFuture<UserAgentList> parseAsync(final String... userAgents) {
319+
return supplyAsync(() -> parse(userAgents));
320+
}
321+
322+
private <T> CompletableFuture<T> supplyAsync(final IpregistrySupplier<T> supplier) {
323+
return CompletableFuture.supplyAsync(() -> {
324+
try {
325+
return supplier.get();
326+
} catch (final IpregistryException e) {
327+
throw new CompletionException(e);
328+
}
329+
}, executor);
330+
}
331+
332+
/**
333+
* A supplier whose computation may fail with an {@link IpregistryException}.
334+
*
335+
* @param <T> the type of the supplied result.
336+
*/
337+
@FunctionalInterface
338+
private interface IpregistrySupplier<T> {
339+
340+
T get() throws IpregistryException;
341+
342+
}
343+
344+
/**
345+
* Releases the resources held by this client: the underlying HTTP connection pool and, when the
346+
* client created it, the executor backing the asynchronous API. Once closed, the client must no
347+
* longer be used.
251348
*
252349
* @throws IOException if closing the underlying request handler fails.
253350
*/
@@ -259,6 +356,10 @@ public void close() throws IOException {
259356
throw e;
260357
} catch (final Exception e) {
261358
throw new IOException(e);
359+
} finally {
360+
if (ownsExecutor) {
361+
executor.close();
362+
}
262363
}
263364
}
264365

src/main/java/co/ipregistry/api/client/IpregistryConfig.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import lombok.Getter;
2121
import lombok.NonNull;
2222

23+
import java.util.concurrent.ExecutorService;
24+
2325

2426
/**
2527
* Configuration settings for the Ipregistry API client.
@@ -51,9 +53,11 @@ public class IpregistryConfig {
5153
* @param retryInterval the base backoff interval, in milliseconds
5254
* @param retryOnServerError whether to retry on 5xx server errors
5355
* @param retryOnTooManyRequests whether to retry on 429 Too Many Requests
56+
* @param executor the executor backing the asynchronous API, or {@code null} to use a per-client default
5457
*/
5558
public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive, int connectionTimeout, int socketTimeout,
56-
int retryMaxAttempts, long retryInterval, boolean retryOnServerError, boolean retryOnTooManyRequests) {
59+
int retryMaxAttempts, long retryInterval, boolean retryOnServerError, boolean retryOnTooManyRequests,
60+
ExecutorService executor) {
5761
this.apiKey = apiKey;
5862
this.baseUrl = baseUrl;
5963
this.connectionKeepAlive = connectionKeepAlive;
@@ -63,6 +67,7 @@ public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive,
6367
this.retryInterval = retryInterval;
6468
this.retryOnServerError = retryOnServerError;
6569
this.retryOnTooManyRequests = retryOnTooManyRequests;
70+
this.executor = executor;
6671
}
6772

6873
/**
@@ -133,4 +138,12 @@ public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive,
133138
@Builder.Default
134139
private final boolean retryOnTooManyRequests = false;
135140

141+
/**
142+
* The executor backing the asynchronous {@code lookupAsync}/{@code parseAsync} methods. When left
143+
* {@code null} (the default), each {@link IpregistryClient} creates and owns a virtual-thread-per-task
144+
* executor that is shut down when the client is closed. When a custom executor is supplied, the caller
145+
* retains ownership and it is not shut down by the client.
146+
*/
147+
private final ExecutorService executor;
148+
136149
}

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import co.ipregistry.api.client.cache.InMemoryCache;
3636
import co.ipregistry.api.client.cache.NoCache;
3737
import co.ipregistry.api.client.cache.IpregistryCache;
38+
import co.ipregistry.api.client.exceptions.ApiException;
3839
import co.ipregistry.api.client.exceptions.IpregistryException;
3940
import co.ipregistry.api.client.model.IpInfo;
4041
import co.ipregistry.api.client.model.IpInfoList;
@@ -48,6 +49,11 @@
4849
import java.io.Closeable;
4950
import java.util.Arrays;
5051
import java.util.List;
52+
import java.util.concurrent.CompletableFuture;
53+
import java.util.concurrent.ExecutionException;
54+
import java.util.concurrent.ExecutorService;
55+
import java.util.concurrent.Executors;
56+
import java.util.concurrent.RejectedExecutionException;
5157

5258
class IpregistryClientTest {
5359

@@ -218,4 +224,67 @@ void testInjectedHttpClientNotClosedByClientClose() throws Exception {
218224
Mockito.verify(httpClient, Mockito.never()).close();
219225
}
220226

227+
@Test
228+
void testLookupAsyncReturnsValue() throws Exception {
229+
final IpregistryConfig config =
230+
IpregistryConfig.builder().apiKey("test").build();
231+
final IpregistryCache cache = Mockito.spy(new InMemoryCache());
232+
final DefaultRequestHandler requestHandler = Mockito.spy(new DefaultRequestHandler(config));
233+
234+
final IpInfo ipdata = new IpInfo();
235+
Mockito.doReturn(ipdata).when(requestHandler).lookup("8.8.8.8");
236+
237+
try (IpregistryClient client = new IpregistryClient(config, cache, requestHandler)) {
238+
final CompletableFuture<IpInfo> future = client.lookupAsync("8.8.8.8");
239+
Assertions.assertSame(ipdata, future.get());
240+
}
241+
}
242+
243+
@Test
244+
void testLookupAsyncCompletesExceptionallyWithApiException() throws Exception {
245+
final IpregistryConfig config =
246+
IpregistryConfig.builder().apiKey("test").build();
247+
final IpregistryCache cache = Mockito.spy(new InMemoryCache());
248+
final DefaultRequestHandler requestHandler = Mockito.spy(new DefaultRequestHandler(config));
249+
250+
Mockito.doThrow(new ApiException("INVALID_API_KEY", "bad key", "fix it"))
251+
.when(requestHandler).lookup("8.8.8.8");
252+
253+
try (IpregistryClient client = new IpregistryClient(config, cache, requestHandler)) {
254+
final CompletableFuture<IpInfo> future = client.lookupAsync("8.8.8.8");
255+
final ExecutionException ex = Assertions.assertThrows(ExecutionException.class, future::get);
256+
Assertions.assertInstanceOf(ApiException.class, ex.getCause());
257+
}
258+
}
259+
260+
@Test
261+
void testInjectedExecutorNotClosedByClientClose() throws Exception {
262+
final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
263+
final IpregistryConfig config =
264+
IpregistryConfig.builder().apiKey("test").executor(executor).build();
265+
final DefaultRequestHandler requestHandler = Mockito.mock(DefaultRequestHandler.class);
266+
267+
final IpregistryClient client =
268+
new IpregistryClient(config, NoCache.getInstance(), requestHandler);
269+
client.close();
270+
271+
// A caller-provided executor remains owned by the caller and must not be shut down.
272+
Assertions.assertFalse(executor.isShutdown());
273+
executor.close();
274+
}
275+
276+
@Test
277+
void testOwnedExecutorShutDownOnClientClose() throws Exception {
278+
final IpregistryConfig config =
279+
IpregistryConfig.builder().apiKey("test").build();
280+
final DefaultRequestHandler requestHandler = Mockito.mock(DefaultRequestHandler.class);
281+
282+
final IpregistryClient client =
283+
new IpregistryClient(config, NoCache.getInstance(), requestHandler);
284+
client.close();
285+
286+
// The default per-client executor is shut down on close, so further async calls are rejected.
287+
Assertions.assertThrows(RejectedExecutionException.class, () -> client.lookupAsync("8.8.8.8"));
288+
}
289+
221290
}

0 commit comments

Comments
 (0)