blob: 0a405e1b63bab5ead85805c2e441bdf27dd82455 [file]
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
<document xmlns="http://maven.apache.org/XDOC/2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/XDOC/2.0 http://maven.apache.org/xsd/xdoc-2.0.xsd">
<properties>
<title>HTTP/2 Java Client — Sample Code</title>
</properties>
<body>
<section name="HTTP/2 Java Client — Sample Code">
<p><strong>What this is:</strong> A standalone sample client
(<code>Http2JsonClient</code>) that demonstrates how to call Axis2
JSON-RPC services over HTTP/2 from plain Java using Apache HttpClient 5.
It is <em>not</em> part of the Axis2 framework — it is example code
in the userguide samples that you can copy and adapt for your own
project.</p>
<p><strong>Why it exists:</strong> Java's built-in
<code>HttpURLConnection</code> does not support HTTP/2. Apache
HttpClient 5's convenience classes (<code>SimpleHttpRequest</code> /
<code>SimpleHttpResponse</code>) support HTTP/2 but silently buffer
the entire response in memory, defeating the streaming benefit.
This sample shows the correct pattern — using
<code>AbstractBinResponseConsumer</code> with the async API — so
you don't have to rediscover it the hard way.</p>
<p>Two execution modes:</p>
<ul>
<li><strong>Buffered</strong> — returns the full response as a
<code>String</code>. Simple, suitable for responses that fit
in memory.</li>
<li><strong>Streaming</strong> — writes response bytes to an
<code>OutputStream</code> in 64KB chunks as HTTP/2 DATA
frames arrive. Memory stays flat regardless of response size.
When paired with the
<a href="json-streaming-formatter.html">Streaming JSON
Formatter</a> (AXIS2-6103), data flows end-to-end in 64KB
chunks.</li>
</ul>
</section>
<section name="The SimpleHttp* Pitfall">
<p>Apache HttpClient 5 provides <code>SimpleHttpRequest</code> and
<code>SimpleHttpResponse</code> as convenience classes for async
requests. <strong>Do not use them for HTTP/2 workloads with large
responses.</strong> They appear to work, but they silently defeat
HTTP/2 streaming.</p>
<p><code>SimpleHttpResponse</code> is a buffering response object —
it accumulates the entire response body in memory before returning
it to the caller. For a 100MB response:</p>
<ul>
<li><code>SimpleHttpResponse</code>: allocates 100MB+ of heap
(internal byte arrays, header maps, content type parsing)
before your code sees a single byte</li>
<li><code>AbstractBinResponseConsumer</code>: your
<code>data(ByteBuffer)</code> callback fires for each 64KB
HTTP/2 DATA frame — memory stays flat at ~64KB working
set</li>
</ul>
<p>This is not obvious from the HttpClient 5 documentation, and
it is easy to write code that uses <code>SimpleHttpResponse</code>,
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 <code>AbstractBinResponseConsumer</code> for all requests,
including the buffered convenience method.</p>
</section>
<section name="Dependencies">
<p>Requires Java 11+ (ALPN built in) and Apache HttpClient 5.4+:</p>
<source>
&lt;!-- Maven --&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.apache.httpcomponents.client5&lt;/groupId&gt;
&lt;artifactId&gt;httpclient5&lt;/artifactId&gt;
&lt;version&gt;5.4.3&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.apache.httpcomponents.core5&lt;/groupId&gt;
&lt;artifactId&gt;httpcore5-h2&lt;/artifactId&gt;
&lt;version&gt;5.4.3&lt;/version&gt;
&lt;/dependency&gt;
</source>
<p>These are the same dependencies used by Axis2's own
<code>H2TransportSender</code>. If you are already running Axis2
with HTTP/2 transport, they are already on your classpath.</p>
<p>The per-probe TCP-keepalive settings shown under Production
Hardening (<code>setTcpKeepIdle/Interval/Count</code>) require
httpcore5 5.3 or later.</p>
</section>
<section name="Buffered Execution">
<p>POST JSON-RPC to any Axis2 service, get the response as a String:</p>
<source>
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();
</source>
<p>The client negotiates HTTP/2 via ALPN on the TLS handshake.
Connections are pooled and multiplexed — multiple concurrent requests
share a single TCP connection.</p>
</section>
<section name="Streaming Execution">
<p>For large responses (10MB+), stream to a file or parser instead
of buffering in heap:</p>
<source>
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();
</source>
<p>Each HTTP/2 DATA frame triggers a callback that writes directly
to your <code>OutputStream</code>. The <code>capacityIncrement()</code>
returns 64KB, creating natural HTTP/2 flow control backpressure —
the client tells the server "I can accept 64KB more" after each
chunk.</p>
<p>When the server uses the
<a href="json-streaming-formatter.html">Streaming JSON Formatter</a>,
data flows end-to-end without full-body buffering on either side:</p>
<source>
Server (MoshiStreamingMessageFormatter)
→ FlushingOutputStream flushes every 64KB
→ HTTP/2 DATA frames
→ Http2JsonClient.data() callback
→ your OutputStream
</source>
</section>
<section name="Production Hardening">
<p>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.</p>
<subsection name="1. Stale-connection defenses">
<p>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:</p>
<source>
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();
</source>
<p><code>validateAfterInactivity</code> revalidates a pooled connection
before it is leased if it has been idle beyond the threshold;
<code>timeToLive</code> 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.</p>
</subsection>
<subsection name="2. TCP keepalive">
<p>The defenses above catch a connection the peer closed cleanly (FIN/RST).
They do not catch a <em>silently</em> 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:</p>
<source>
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();
</source>
<p>With these, a dead peer is detected in roughly 75-105s instead of the
OS keepalive default (commonly two hours). Two caveats:</p>
<ul>
<li>The per-probe knobs (<code>TcpKeepIdle/Interval/Count</code>) use
<code>jdk.net.ExtendedSocketOptions</code> and require Java 11+ on
Linux or macOS. On other platforms <code>setSoKeepAlive(true)</code>
still works but falls back to the OS default idle time.</li>
<li>Keep the idle time <strong>below the shortest idle-drop on the
network path</strong>. 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.</li>
</ul>
</subsection>
<subsection name="3. Retry on stale-connection failures">
<p>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:</p>
<source>
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
}
</source>
<p>Two rules keep retry safe:</p>
<ul>
<li><strong>Idempotency</strong> — only retry operations that can run
twice without side effects. A read/query is fine; a non-idempotent
write is not.</li>
<li><strong>No partial output</strong> — when streaming to a caller's
<code>OutputStream</code>, 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 <code>execute</code> path uses a
fresh buffer per attempt, so it is always safe.)</li>
</ul>
<p>A first-attempt <code>Future.get()</code> <code>TimeoutException</code> 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.</p>
</subsection>
<subsection name="4. Fail fast on the response status">
<p>This is why the sample uses <code>AbstractBinResponseConsumer</code>
rather than <code>SimpleHttpResponse</code> for <em>every</em> request,
including the buffered one. The consumer's <code>start()</code> 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 <code>start()</code> turns
that multi-minute hang into an immediate, retryable failure.</p>
</subsection>
</section>
<section name="Timeout and Cancellation">
<p>Both methods accept a <code>timeoutSeconds</code> parameter for
<code>Future.get()</code>. If the timeout expires or the thread is
interrupted, the underlying HTTP request is cancelled to prevent
zombie requests that would continue consuming resources:</p>
<source>
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>
</section>
<section name="Source Code">
<p>The complete sample client is available on GitHub:</p>
<p><a href="https://github.com/apache/axis-axis2-java-core/blob/master/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/client/Http2JsonClient.java">
Http2JsonClient.java on GitHub</a></p>
<p>Copy and adapt it for your project. It has no dependency on
Axis2 itself — only Apache HttpClient 5 and httpcore5-h2.</p>
</section>
</body>
</document>