The AsyncArk client returns CompletableFuture<T> - non-blocking execution using Java's built-in async primitives.
<dependency>
<groupId>xyz.juandiii</groupId>
<artifactId>ark-async</artifactId>
</dependency>
<dependency>
<groupId>xyz.juandiii</groupId>
<artifactId>ark-transport-jdk</artifactId>
</dependency>AsyncArk client = AsyncArkClient.builder()
.serializer(new JacksonSerializer(new ObjectMapper()))
.transport(new ArkJdkAsyncTransport(HttpClient.newBuilder().build()))
.baseUrl("https://api.example.com")
.build();ArkJdkAsyncTransport implements Transport<CompletableFuture<RawResponse>>. It is a sibling of ArkJdkSyncTransport: both can wrap the same underlying Java HttpClient if you want to share the connection pool between sync and async modes.
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.sslContext(sslContext)
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
ArkJdkAsyncTransport asyncTransport = new ArkJdkAsyncTransport(httpClient);
// ArkJdkSyncTransport syncTransport = new ArkJdkSyncTransport(httpClient); // shares poolCompletableFuture<User> user = client.get("/users/1")
.retrieve()
.body(User.class);CompletableFuture<User> created = client.post("/users")
.contentType(MediaType.APPLICATION_JSON)
.body(new User("Juan", "juan@example.com"))
.retrieve()
.body(User.class);CompletableFuture<ArkResponse<User>> response = client.get("/users/1")
.retrieve()
.toEntity(User.class);
response.thenAccept(r -> {
int status = r.statusCode();
User body = r.body();
});CompletableFuture<User> user = client.get("/slow-endpoint")
.timeout(Duration.ofSeconds(60))
.retrieve()
.body(User.class);See Error Handling for the full exception hierarchy. Errors propagate through the CompletableFuture:
client.get("/users/1")
.retrieve()
.body(User.class)
.exceptionally(ex -> {
if (ex.getCause() instanceof NotFoundException) {
return User.unknown();
}
throw new CompletionException(ex);
});By default, Ark fails the CompletableFuture with an ApiException
subtype for any HTTP 4xx/5xx status. When 4xx is a meaningful business
outcome, opt out and inspect the response.
Per-request opt-out via .noThrow():
CompletableFuture<ArkResponse<User>> response = client.get("/users/1")
.noThrow()
.retrieve()
.toEntity(User.class);
response.thenAccept(r -> {
if (r.statusCode() == 404) {
// not found, treat as business outcome
} else if (r.isSuccessful()) {
User body = r.body();
}
});Client-level default via throwOnError(false):
AsyncArk permissive = AsyncArkClient.builder()
.serializer(serializer)
.transport(transport)
.baseUrl("https://api.example.com")
.throwOnError(false)
.build();When you need the raw response — status, headers, and body as a String —
without going through deserialization (e.g. to inspect an error body that
doesn't match your typed schema), use .raw():
CompletableFuture<RawResponse> futureRaw = client.get("/users/1")
.noThrow()
.retrieve()
.raw();
futureRaw.thenAccept(raw -> {
if (raw.isError()) {
log.warn("Error {}: {}", raw.statusCode(), raw.body());
} else {
User user = serializer.deserialize(raw.body(), User.class);
}
});.raw() returns a CompletableFuture<RawResponse> (no deserialization).
Use it together with .noThrow() (or client-level throwOnError(false))
to inspect bodies on 4xx/5xx without the future failing.
.retrieve().bodyAsBytes() returns a CompletableFuture<byte[]> with the response bytes exactly as received (for example, file downloads).