Skip to content

http/2: prototype ALPN fallback from HTTP/2 to HTTP/1.1 for client connections - #1250

Draft
pjfanning wants to merge 3 commits into
apache:mainfrom
pjfanning:http2-client-alpn-fallback
Draft

http/2: prototype ALPN fallback from HTTP/2 to HTTP/1.1 for client connections#1250
pjfanning wants to merge 3 commits into
apache:mainfrom
pjfanning:http2-client-alpn-fallback

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Prototype for #1249, which is the blocker for #483.

Adds OutgoingConnectionBuilder.http2WithFallback(): it offers both h2 and http/1.1 over 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

ProtocolSwitch decides which stack to install on the first inbound SessionBytes. That is sound server-side, because in HTTP the client always speaks first. Mirroring it client-side deadlocks: if negotiation lands on http/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:

  • Pekko's TLS stage emits no handshake-complete event — SslTlsInbound is only SessionBytes or SessionTruncated, and SessionBytes only arrives when there is application data.
  • SSLSession does not expose the negotiated protocol; only SSLEngine.getApplicationProtocol does.
  • setHandshakeApplicationProtocolSelector is only meaningful for the peer that selects the protocol (the server).
  • HandshakeCompletedListener is SSLSocket-only.

What's here

AlpnObservingSSLEngine — delegating SSLEngine that watches getApplicationProtocol around wrap/unwrap and 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 treating UnsupportedOperationException as "no protocol".

ClientProtocolSwitch — client-side counterpart of ProtocolSwitch, a 4-port BidiFlow stage that installs either layer once the promise completes. Two things it has to handle that the server side does not:

  • Inbound elements can overtake the AsyncCallback (an HTTP/2 server sends SETTINGS immediately after the handshake), so they are buffered and replayed into the installed layer.
  • The preStart pull on netIn may still be outstanding at install time, and an element can arrive before the sub-stream has demand — so connectIn guards against pulling twice and buffers what it cannot push yet. Without that guard this fails with Cannot pull port (ClientProtocolSwitch.netIn) twice.

Unlike ProtocolSwitch it needs no ServerTerminator plumbing: Http.ClientLayer and Http2Blueprint.clientStack.atop(unwrapTls) have the same shape and both carry NotUsed.

Http2Ext.outgoingConnectionWithNegotiation — builds the stack inside Flow.fromMaterializer so every materialization gets its own engine and promise. This matters: PersistentConnection.managedConnection re-materializes the connection flow on each reconnect, so the closed-over var plus "not reusable" guard that httpsWithAlpn uses server-side would break reconnection here.

Prerequisite bug fixHttp2JDKAlpnSupport.clientSetApplicationProtocols ignored its protocols parameter and hardcoded Array("h2"). Harmless before, since the only caller passed exactly that, but it is the seam this feature needs.

Tests

New Http2ClientFallbackSpec covers both directions against a real TLS server, using Http2.streamId presence on the server-side request as the discriminator for which stack actually ran:

  • negotiates HTTP/2 against a server that supports it
  • falls back to HTTP/1.1 against a server bound with withEnableHttp2(false)

Locally: the two new tests pass, and Http2ClientServerSpec, Http2ClientSpec, Http2PersistentClientSpec, Http2ServerSpec and TelemetrySpiSpec still pass (175 succeeded, 0 failed). http-core/mimaReportBinaryIssues, scalafmtAll, javafmtAll and headerCheck are clean.

Open questions

  • Naming. http2WithFallback() sits next to http2(); other options are httpsWithNegotiation() or making it the behaviour of http2() itself. I kept http2() untouched deliberately — changing it would also change the ALPN list it puts on the wire for existing users.
  • managedPersistentHttp2WithFallback() is not included. PersistentConnection assumes HTTP/2 multiplexing semantics, and I did not want to guess at how it should behave over a fallback connection.
  • The delegating engine is ~30 mechanical methods. The alternative recorded in HTTP/2 client ALPN fallback needs a handshake-complete signal that the TLS stage does not emit #1249 is a TimerGraphStageLogic polling getApplicationProtocol, which is far less code but puts a poll loop in connection setup. Happy to switch if reviewers prefer that trade.
  • Connection pool. Untouched; this is connection-level only.

🤖 Generated with Claude Code

pjfanning and others added 2 commits August 30, 2026 12:45
…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
pjfanning force-pushed the http2-client-alpn-fallback branch from 00304f5 to 054abfc Compare August 30, 2026 11:45
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant