http/2: prototype ALPN fallback from HTTP/2 to HTTP/1.1 for client connections - #1250
Draft
pjfanning wants to merge 3 commits into
Draft
http/2: prototype ALPN fallback from HTTP/2 to HTTP/1.1 for client connections#1250pjfanning wants to merge 3 commits into
pjfanning wants to merge 3 commits into
Conversation
…nnections
The HTTP/2 client offers only `h2` over ALPN and then unconditionally builds
the HTTP/2 stack, so a server without HTTP/2 support fails the stream.
Adds `OutgoingConnectionBuilder.http2WithFallback()`, which offers both `h2`
and `http/1.1` and installs the client layer matching what the server selected.
The decision needs a "handshake complete" signal. ProtocolSwitch decides on the
first inbound SessionBytes, which works server-side because the client always
speaks first, but deadlocks here: on HTTP/1.1 the server stays silent until it
gets a request and the switch would be holding that request. Neither the TLS
stage nor SSLEngine offers such an event, so AlpnObservingSSLEngine watches
getApplicationProtocol around wrap/unwrap and completes a promise that
ClientProtocolSwitch waits on through an AsyncCallback.
The stack is built inside Flow.fromMaterializer so each materialization gets its
own engine and promise - PersistentConnection re-materializes the connection
flow on every reconnect, so the closed-over var that httpsWithAlpn uses
server-side would not work.
Also fixes Http2JDKAlpnSupport.clientSetApplicationProtocols ignoring its
`protocols` parameter and hardcoding Array("h2"), which is the seam this needs.
Refs apache#1249, apache#483
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
connectIn ran every element through the replay buffer, allocating a Vector append on push and a tail on drain for the whole life of the connection. The buffer only exists to hold elements that overtake the negotiation callback, so push straight to the sub-source when nothing is pending and demand is there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pjfanning
force-pushed
the
http2-client-alpn-fallback
branch
from
August 30, 2026 11:45
00304f5 to
054abfc
Compare
ApiMayChangeDocCheckerSpec scans for @ApiMayChange members and requires each to be named in compatibility-guidelines.md, so adding the annotated method to both OutgoingConnectionBuilder traits broke it. sbt "docs/testOnly docs.ApiMayChangeDocCheckerSpec" - 2 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prototype for #1249, which is the blocker for #483.
Adds
OutgoingConnectionBuilder.http2WithFallback(): it offers bothh2andhttp/1.1over ALPN and runs the client layer matching whatever the server selected, instead of failing the stream when the server has no HTTP/2 support.Opening as a draft — the mechanism works and is tested, but the shape is up for discussion (see Open questions below).
The problem this had to solve
ProtocolSwitchdecides which stack to install on the first inboundSessionBytes. That is sound server-side, because in HTTP the client always speaks first. Mirroring it client-side deadlocks: if negotiation lands onhttp/1.1, the server sends nothing until it receives a request, and the switch is sitting on that request waiting for inbound bytes.So the client needs a "handshake complete, ALPN known" signal, and none of the obvious sources provide one:
TLSstage emits no handshake-complete event —SslTlsInboundis onlySessionBytesorSessionTruncated, andSessionBytesonly arrives when there is application data.SSLSessiondoes not expose the negotiated protocol; onlySSLEngine.getApplicationProtocoldoes.setHandshakeApplicationProtocolSelectoris only meaningful for the peer that selects the protocol (the server).HandshakeCompletedListenerisSSLSocket-only.What's here
AlpnObservingSSLEngine— delegatingSSLEnginethat watchesgetApplicationProtocolaroundwrap/unwrapand reports the result once, the first time it goes non-null. Passes the empty string through when the peer negotiated nothing, which is what the JDK reports for a server that ignores ALPN. Falls back to treatingUnsupportedOperationExceptionas "no protocol".ClientProtocolSwitch— client-side counterpart ofProtocolSwitch, a 4-portBidiFlowstage that installs either layer once the promise completes. Two things it has to handle that the server side does not:AsyncCallback(an HTTP/2 server sends SETTINGS immediately after the handshake), so they are buffered and replayed into the installed layer.preStartpull onnetInmay still be outstanding at install time, and an element can arrive before the sub-stream has demand — soconnectInguards against pulling twice and buffers what it cannot push yet. Without that guard this fails withCannot pull port (ClientProtocolSwitch.netIn) twice.Unlike
ProtocolSwitchit needs noServerTerminatorplumbing:Http.ClientLayerandHttp2Blueprint.clientStack.atop(unwrapTls)have the same shape and both carryNotUsed.Http2Ext.outgoingConnectionWithNegotiation— builds the stack insideFlow.fromMaterializerso every materialization gets its own engine and promise. This matters:PersistentConnection.managedConnectionre-materializes the connection flow on each reconnect, so the closed-overvarplus "not reusable" guard thathttpsWithAlpnuses server-side would break reconnection here.Prerequisite bug fix —
Http2JDKAlpnSupport.clientSetApplicationProtocolsignored itsprotocolsparameter and hardcodedArray("h2"). Harmless before, since the only caller passed exactly that, but it is the seam this feature needs.Tests
New
Http2ClientFallbackSpeccovers both directions against a real TLS server, usingHttp2.streamIdpresence on the server-side request as the discriminator for which stack actually ran:withEnableHttp2(false)Locally: the two new tests pass, and
Http2ClientServerSpec,Http2ClientSpec,Http2PersistentClientSpec,Http2ServerSpecandTelemetrySpiSpecstill pass (175 succeeded, 0 failed).http-core/mimaReportBinaryIssues,scalafmtAll,javafmtAllandheaderCheckare clean.Open questions
http2WithFallback()sits next tohttp2(); other options arehttpsWithNegotiation()or making it the behaviour ofhttp2()itself. I kepthttp2()untouched deliberately — changing it would also change the ALPN list it puts on the wire for existing users.managedPersistentHttp2WithFallback()is not included.PersistentConnectionassumes HTTP/2 multiplexing semantics, and I did not want to guess at how it should behave over a fallback connection.TimerGraphStageLogicpollinggetApplicationProtocol, which is far less code but puts a poll loop in connection setup. Happy to switch if reviewers prefer that trade.🤖 Generated with Claude Code