NOTE this bug report was written with the help from Claude Opus 4.8, but bug and workaround verified by me.
Version: async-http-client 3.0.12 (Netty 4.2.16.Final). Regression introduced in 3.0.8.
Summary
When an HTTPS request is routed through an HTTP forward proxy (ProxyServer), AHC establishes the CONNECT tunnel, performs the TLS handshake to the target, and ALPN negotiates h2 (HTTP/2 is enabled by default and advertised). However, on the proxy-tunnel code path AHC installs an HTTP/1.1HttpClientCodec and never upgrades to HTTP/2. The h2 response frames are then decoded by the HTTP/1.1 decoder, which fails on the first frame's leading 0x00 byte:
io.netty.handler.codec.http.InvalidLineSeparatorException: Line Feed must be
preceded by Carriage Return when terminating HTTP start- and header field-lines
(On older Netty without strict line parsing this manifests as silent corruption / a different protocol error rather than a clean exception.)
The same request without a proxy works, because the direct path does check the negotiated ALPN protocol and upgrades the pipeline to HTTP/2.
Regression range
Works on 3.0.1 (no HTTP/2 support, ALPN only offers http/1.1). Most likely breaks from 3.0.8 onward, where HTTP/2 support was added (PR #2144 "Add Support for Multiplexing HTTP/2") and enabled by default — h2 is now advertised on the proxy tunnel but not handled there.
Environment
- async-http-client 3.0.8–3.0.12
- Netty 4.2.16.Final (netty-codec-http)
- Target: an h2-capable HTTPS origin (reproduced against Amazon CloudFront)
- Proxy: any plain HTTP forward proxy honoring
CONNECT (reproduced with tinyproxy 1.11.2) http2Enabled left at its default (true)
Root cause (source references)
Direct (no-proxy) path correctly upgrades to h2 after the handshake — NettyConnectListener (onSuccess):
// Detect ALPN-negotiated protocol and upgrade pipeline to HTTP/2 if "h2" was selectedStringalpnProtocol = sslHandler.applicationProtocol();
if (ApplicationProtocolNames.HTTP_2.equals(alpnProtocol)) {
channelManager.upgradePipelineToHttp2(channel.pipeline());
...
}Proxy CONNECT-tunnel path never checks ALPN — ChannelManager.updatePipelineForHttpTunneling(...):
SslHandlersslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
whenHandshaked = sslHandler.handshakeFuture();
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); // always HTTP/1.1
...
returnwhenHandshaked;
createSslHandler uses the same ALPN-enabled SslContext (advertising h2, http/1.1 via DefaultSslEngineFactory), so the tunnel TLS session negotiates h2 — but the pipeline is hardwired to an HTTP/1.1 codec.
The caller only waits for the handshake, then writes — it does not inspect applicationProtocol() nor call upgradePipelineToHttp2 — NettyRequestSender.drainChannelAndExecuteNextRequest(..., Future<Channel> whenHandshaked):
whenHandshaked.addListener(f -> {
if (f.isSuccess()) {
sendNextRequest(nextRequest, future); // no ALPN / h2 check
} else {
future.abort(f.cause());
}
});Entry point: ConnectSuccessInterceptor.exitAfterHandlingConnect → updatePipelineForHttpTunneling(...).
So the h2-upgrade logic that exists on the direct path is missing on the proxy-tunnel path.
Steps to reproduce
- Run any HTTP forward proxy that supports
CONNECT (e.g. tinyproxy) on localhost:8888. - Send an HTTPS request to an h2-capable origin through it, with defaults (h2 enabled):
try (AsyncHttpClientclient = Dsl.asyncHttpClient(Dsl.config()
.setProxyServer(newProxyServer.Builder("localhost", 8888)))) {
Responser = client.prepareOptions("https://<h2-origin>/...")
.execute().toCompletableFuture().get();
}- The request fails with
InvalidLineSeparatorException. Hex-dumping the decrypted inbound bytes (handler inserted right after the ssl handler) shows HTTP/2 frames, e.g. 00 00 12 04 00 ... (SETTINGS) and 00 01 96 01 05 00 00 00 03 ... (HEADERS). - Removing
.setProxyServer(...) (direct) makes the same request succeed over h2.
Expected
On the proxy CONNECT-tunnel path, AHC should inspect the ALPN-negotiated protocol after the target TLS handshake and call upgradePipelineToHttp2(...) when h2 was selected — mirroring the direct path — or, at minimum, not advertise h2 in ALPN when it cannot handle h2 on this path.
Workaround
Disable HTTP/2 for clients that go through a forward proxy, so ALPN only offers http/1.1:
Dsl.config().setHttp2Enabled(false).setProxyServer(...)
NOTE this bug report was written with the help from Claude Opus 4.8, but bug and workaround verified by me.
Version: async-http-client 3.0.12 (Netty 4.2.16.Final). Regression introduced in 3.0.8.
Summary
When an HTTPS request is routed through an HTTP forward proxy (
ProxyServer), AHC establishes the CONNECT tunnel, performs the TLS handshake to the target, and ALPN negotiates h2 (HTTP/2 is enabled by default and advertised). However, on the proxy-tunnel code path AHC installs an HTTP/1.1HttpClientCodecand never upgrades to HTTP/2. The h2 response frames are then decoded by the HTTP/1.1 decoder, which fails on the first frame's leading0x00byte:(On older Netty without strict line parsing this manifests as silent corruption / a different protocol error rather than a clean exception.)
The same request without a proxy works, because the direct path does check the negotiated ALPN protocol and upgrades the pipeline to HTTP/2.
Regression range
Works on 3.0.1 (no HTTP/2 support, ALPN only offers
http/1.1). Most likely breaks from 3.0.8 onward, where HTTP/2 support was added (PR #2144 "Add Support for Multiplexing HTTP/2") and enabled by default — h2 is now advertised on the proxy tunnel but not handled there.Environment
CONNECT(reproduced with tinyproxy 1.11.2)http2Enabledleft at its default (true)Root cause (source references)
Direct (no-proxy) path correctly upgrades to h2 after the handshake —
NettyConnectListener(onSuccess):Proxy CONNECT-tunnel path never checks ALPN —
ChannelManager.updatePipelineForHttpTunneling(...):createSslHandleruses the same ALPN-enabledSslContext(advertisingh2, http/1.1viaDefaultSslEngineFactory), so the tunnel TLS session negotiates h2 — but the pipeline is hardwired to an HTTP/1.1 codec.The caller only waits for the handshake, then writes — it does not inspect
applicationProtocol()nor callupgradePipelineToHttp2—NettyRequestSender.drainChannelAndExecuteNextRequest(..., Future<Channel> whenHandshaked):Entry point:
ConnectSuccessInterceptor.exitAfterHandlingConnect→updatePipelineForHttpTunneling(...).So the h2-upgrade logic that exists on the direct path is missing on the proxy-tunnel path.
Steps to reproduce
CONNECT(e.g. tinyproxy) onlocalhost:8888.InvalidLineSeparatorException. Hex-dumping the decrypted inbound bytes (handler inserted right after thesslhandler) shows HTTP/2 frames, e.g.00 00 12 04 00 ...(SETTINGS) and00 01 96 01 05 00 00 00 03 ...(HEADERS)..setProxyServer(...)(direct) makes the same request succeed over h2.Expected
On the proxy CONNECT-tunnel path, AHC should inspect the ALPN-negotiated protocol after the target TLS handshake and call
upgradePipelineToHttp2(...)whenh2was selected — mirroring the direct path — or, at minimum, not advertise h2 in ALPN when it cannot handle h2 on this path.Workaround
Disable HTTP/2 for clients that go through a forward proxy, so ALPN only offers
http/1.1: