HTTP/2 Java Client — Sample Code

What this is: A standalone sample client (Http2JsonClient) that demonstrates how to call Axis2 JSON-RPC services over HTTP/2 from plain Java using Apache HttpClient 5. It is not part of the Axis2 framework — it is example code in the userguide samples that you can copy and adapt for your own project.

Why it exists: Java's built-in HttpURLConnection does not support HTTP/2. Apache HttpClient 5's convenience classes (SimpleHttpRequest / SimpleHttpResponse) support HTTP/2 but silently buffer the entire response in memory, defeating the streaming benefit. This sample shows the correct pattern — using AbstractBinResponseConsumer with the async API — so you don't have to rediscover it the hard way.

Two execution modes:

  • Buffered — returns the full response as a String. Simple, suitable for responses that fit in memory.
  • Streaming — writes response bytes to an OutputStream in 64KB chunks as HTTP/2 DATA frames arrive. Memory stays flat regardless of response size. When paired with the Streaming JSON Formatter (AXIS2-6103), data flows end-to-end in 64KB chunks.

The SimpleHttp* Pitfall

Apache HttpClient 5 provides SimpleHttpRequest and SimpleHttpResponse as convenience classes for async requests. Do not use them for HTTP/2 workloads with large responses. They appear to work, but they silently defeat HTTP/2 streaming.

SimpleHttpResponse is a buffering response object — it accumulates the entire response body in memory before returning it to the caller. For a 100MB response:

  • SimpleHttpResponse: allocates 100MB+ of heap (internal byte arrays, header maps, content type parsing) before your code sees a single byte
  • AbstractBinResponseConsumer: your data(ByteBuffer) callback fires for each 64KB HTTP/2 DATA frame — memory stays flat at ~64KB working set

This is not obvious from the HttpClient 5 documentation, and it is easy to write code that uses SimpleHttpResponse, observes correct HTTP/2 ALPN negotiation in the logs, and concludes that HTTP/2 streaming is working — when in fact the response is fully buffered before your code runs. The sample client avoids this by using AbstractBinResponseConsumer for all requests, including the buffered convenience method.

Dependencies

Requires Java 17+ (the Axis2 minimum; ALPN is built in) and Apache HttpClient 5.4+. Axis2 2.0.2 builds against httpclient5 5.6.4 and httpcore5 5.4.4:

<!-- Maven -->
<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
    <version>5.6.4</version>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents.core5</groupId>
    <artifactId>httpcore5-h2</artifactId>
    <version>5.4.4</version>
</dependency>

These are the same dependencies used by Axis2's own H2TransportSender. If you are already running Axis2 with HTTP/2 transport, they are already on your classpath.

The per-probe TCP-keepalive settings shown under Production Hardening (setTcpKeepIdle/Interval/Count) require httpcore5 5.3 or later.

Buffered Execution

POST JSON-RPC to any Axis2 service, get the response as a String:

String url = "https://localhost:8443/axis2-json-api/services/FinancialBenchmarkService";
String json = "{\"monteCarlo\":[{\"arg0\":{\"nSimulations\":100000,\"nPeriods\":252,"
    + "\"initialValue\":1000000,\"expectedReturn\":0.10,\"volatility\":0.223,"
    + "\"nPeriodsPerYear\":252,\"randomSeed\":42}}]}";

String response = Http2JsonClient.execute(url, json, 300);
System.out.println(response);

Http2JsonClient.shutdown();

The client negotiates HTTP/2 via ALPN on the TLS handshake. Connections are pooled and multiplexed — multiple concurrent requests share a single TCP connection.

Streaming Execution

For large responses (10MB+), stream to a file or parser instead of buffering in heap:

String url = "https://localhost:8443/axis2-json-api/services/BigDataH2Service";
String json = "{\"generate\":[{\"arg0\":{\"datasetSize\":52428800}}]}";

try (FileOutputStream fos = new FileOutputStream("/tmp/result.json")) {
    int status = Http2JsonClient.executeStreaming(url, json, 300, fos);
    System.out.println("HTTP " + status);
}

Http2JsonClient.shutdown();

Each HTTP/2 DATA frame triggers a callback that writes directly to your OutputStream. The capacityIncrement() returns 64KB, creating natural HTTP/2 flow control backpressure — the client tells the server "I can accept 64KB more" after each chunk.

When the server uses the Streaming JSON Formatter, data flows end-to-end without full-body buffering on either side:

Server (MoshiStreamingMessageFormatter)
  → FlushingOutputStream flushes every 64KB
  → HTTP/2 DATA frames
  → Http2JsonClient.data() callback
  → your OutputStream

Production Hardening

The examples above establish HTTP/2 connectivity, but a long-lived pooled client that runs for days against a load-balanced service needs more than that to stay healthy across upstream restarts and network blips. Learned in production: without the four defenses below, a routine upstream restart or a briefly dropped network path leaves the client dispatching requests onto dead connections that hang until the socket timeout. Each defense is a few lines on the client builder, and the sample applies all four.

1. Stale-connection defenses

A connection pool will hand out a connection the server (or an intermediary) has since closed. After an upstream restart the pool keeps dispatching onto dead, half-open connections; each request hangs until the socket timeout. Three settings prevent this:

ConnectionConfig connectionConfig = ConnectionConfig.custom()
    .setConnectTimeout(Timeout.ofSeconds(30))
    .setValidateAfterInactivity(TimeValue.ofSeconds(10))  // re-check idle connections on lease
    .setTimeToLive(TimeValue.ofMinutes(5))                // cap connection lifetime
    .build();
// ...setDefaultConnectionConfig(connectionConfig) on the connection manager

HttpAsyncClients.custom()
    // ...
    .evictExpiredConnections()
    .evictIdleConnections(TimeValue.ofSeconds(90))        // background drain of idle connections
    .build();

validateAfterInactivity revalidates a pooled connection before it is leased if it has been idle beyond the threshold; timeToLive retires connections regardless of traffic; the background evictor drains idle/expired connections so a restart-killed connection dies proactively rather than being discovered on the next request. All three act only on pooled/idle connections — an in-flight request is never cut.

2. TCP keepalive

The defenses above catch a connection the peer closed cleanly (FIN/RST). They do not catch a silently dead peer — a hard kill, or an idle TCP flow dropped by a NAT gateway or load balancer without notifying either end. Only TCP keepalive detects that:

IOReactorConfig.custom()
    .setSoKeepAlive(true)
    .setTcpKeepIdle(45)       // seconds idle before the first probe
    .setTcpKeepInterval(10)   // seconds between probes
    .setTcpKeepCount(3)       // probes before declaring the peer dead
    .build();

With these, a dead peer is detected in roughly 75-105s instead of the OS keepalive default (commonly two hours). Two caveats:

  • The per-probe knobs (TcpKeepIdle/Interval/Count) use jdk.net.ExtendedSocketOptions and work on Java 17+ on Linux or macOS. On other platforms setSoKeepAlive(true) still works but falls back to the OS default idle time.
  • Keep the idle time below the shortest idle-drop on the network path. NAT gateways commonly reset idle flows at ~350s; cloud load-balancer idle timeouts vary and can be as low as 60s. A keepalive idle longer than the path's timeout defeats the purpose — the flow is already gone before the first probe.

3. Retry on stale-connection failures

Even with validation there is a race: a connection can die between the lease-time check and the request being written. It then fails with a recognizable connection-level exception rather than a response, and retrying once on a fresh connection recovers transparently:

if (cause instanceof ConnectionClosedException          // GOAWAY race / not-executed
        || cause instanceof HttpStreamResetException    // stream reset (incl. H2)
        || cause instanceof H2ConnectionException
        || cause instanceof ClosedChannelException
        || cause instanceof ConnectionRequestTimeoutException
        || cause instanceof DeadlineTimeoutException     // pool-lease timeout
        || cause instanceof SocketException
        || cause instanceof ConnectException
        || cause instanceof SocketTimeoutException) {
    // transient — safe to retry on a fresh, validated connection
}

Two rules keep retry safe:

  • Idempotency — only retry operations that can run twice without side effects. A read/query is fine; a non-idempotent write is not.
  • No partial output — when streaming to a caller's OutputStream, retry only while zero bytes have been written; once any body byte has flowed the stream cannot be rewound, so the failure must surface. (The buffered execute path uses a fresh buffer per attempt, so it is always safe.)

A first-attempt Future.get() TimeoutException is also worth retrying once: it is the common signature of a request dispatched onto a dead pooled connection, and retrying on a validated connection avoids a full-timeout hang without tripling the worst-case latency of a genuinely slow request.

4. Fail fast on the response status

This is why the sample uses AbstractBinResponseConsumer rather than SimpleHttpResponse for every request, including the buffered one. The consumer's start() callback receives the status code the moment response headers arrive — before any body. A failing intermediary (for example a load balancer whose target died mid-request) can return error headers and then never terminate the body stream; code that waits for the full body before checking the status parks for the entire timeout. Reacting to the status in start() turns that multi-minute hang into an immediate, retryable failure.

Timeout and Cancellation

Both methods accept a timeoutSeconds parameter for Future.get(). If the timeout expires or the thread is interrupted, the underlying HTTP request is cancelled to prevent zombie requests that would continue consuming resources:

try {
    response = future.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (Exception e) {
    requestFuture.cancel(true);  // Cancel the HTTP request
    if (e instanceof InterruptedException) {
        Thread.currentThread().interrupt();  // Restore interrupt flag
    }
    throw e;
}

Source Code

The complete sample client is available on GitHub:

Http2JsonClient.java on GitHub

Copy and adapt it for your project. It has no dependency on Axis2 itself — only Apache HttpClient 5 and httpcore5-h2.