Skip to content

feat(binding-http): derive ProxyBeginEx from :authority for stream-driven routing#1713

Open
jfallows wants to merge 7 commits intodevelopfrom
feature/1676-proxybeginex-authority
Open

feat(binding-http): derive ProxyBeginEx from :authority for stream-driven routing#1713
jfallows wants to merge 7 commits intodevelopfrom
feature/1676-proxybeginex-authority

Conversation

@jfallows
Copy link
Copy Markdown
Contributor

@jfallows jfallows commented Apr 7, 2026

Summary

  • Extracts :authority and :scheme headers from HttpBeginExFW when opening a new outbound network stream in HttpClientFactory
  • Parses the authority into host and port with full IPv6 bracket-notation support (e.g. [::1]:8443) and scheme-based port defaults (443 for https, 80 for http)
  • Builds a ProxyBeginExFW with address.inet (family=INET, protocol=STREAM, source=0.0.0.0, destination=host, sourcePort=0, destinationPort=port) and an infos entry of type AUTHORITY carrying the full :authority value for TLS SNI
  • Falls back to EMPTY_OCTETS when no authority is present, preserving existing behaviour
  • Follows the flyweight-on-factory pattern: proxyBeginExRW is a non-static field on HttpClientFactory, reused across all streams on the worker thread

Changes

  • runtime/binding-http/…/stream/HttpClientFactory.java
    • New static import: ProxyAddressProtocol.STREAM
    • New factory field: ProxyBeginExFW.Builder proxyBeginExRW
    • New constant: String8FW HEADER_SCHEME
    • HttpClient.doNetworkBegin — gains authority and scheme parameters; builds ProxyBeginExFW when authority is non-empty
    • HttpExchange.onRequestBegin — extracts :authority and :scheme from headers before calling doNetworkBegin

Test plan

  • Compile runtime/binding-http successfully
  • Run ./mvnw test -pl runtime/binding-http -DskipITs — existing unit tests pass
  • Verify existing spec ITs still pass: ./mvnw verify -pl specs/binding-http.spec
  • Manually confirm ProxyBeginExFW is populated correctly for plain-host, host:port, and IPv6 authorities

Closes #1676

…iven routing (#1676)

Extract :authority and :scheme from HttpBeginExFW headers on new outbound streams,
parse host and port (with IPv6 bracket notation and scheme-based defaults), and
populate ProxyBeginExFW with INET address and AUTHORITY info entry so that downstream
proxy/TLS bindings can route by destination host and perform SNI.

https://claude.ai/code/session_0174raBeXFTgt98bp4DTyRDm
Copy link
Copy Markdown
Contributor Author

@jfallows jfallows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update at least one spec scripts scenario to verify behavior change for client binding.

Comment on lines +4695 to +4703
final HttpHeaderFW authorityHeader = headers.matchFirst(header ->
HEADER_AUTHORITY.equals(header.name()));
final HttpHeaderFW schemeHeader = headers.matchFirst(header ->
HEADER_SCHEME.equals(header.name()));

final String authority = authorityHeader != null ? authorityHeader.value().asString() : null;
final String scheme = schemeHeader != null ? schemeHeader.value().asString() : null;

client.doNetworkBegin(traceId, authorization, 0, authority, scheme);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
final HttpHeaderFW authorityHeader = headers.matchFirst(header ->
HEADER_AUTHORITY.equals(header.name()));
final HttpHeaderFW schemeHeader = headers.matchFirst(header ->
HEADER_SCHEME.equals(header.name()));
final String authority = authorityHeader != null ? authorityHeader.value().asString() : null;
final String scheme = schemeHeader != null ? schemeHeader.value().asString() : null;
client.doNetworkBegin(traceId, authorization, 0, authority, scheme);
final HttpHeaderFW authorityHeader = headers.matchFirst(header ->
HEADER_AUTHORITY.equals(header.name()));
final String authority = authorityHeader != null ? authorityHeader.value().asString() : null;
final HttpHeaderFW schemeHeader = headers.matchFirst(header ->
HEADER_SCHEME.equals(header.name()));
final String scheme = schemeHeader != null ? schemeHeader.value().asString() : null;
client.doNetworkBegin(traceId, authorization, 0, authority, scheme);

Comment on lines +2622 to +2664
if (authority != null && !authority.isEmpty())
{
final String host;
final int port;

if (authority.startsWith("["))
{
final int closeBracket = authority.indexOf(']');
if (closeBracket != -1 && closeBracket + 1 < authority.length() && authority.charAt(closeBracket + 1) == ':')
{
host = authority.substring(1, closeBracket);
port = parseInt(authority.substring(closeBracket + 2));
}
else
{
host = closeBracket != -1 ? authority.substring(1, closeBracket) : authority;
port = "https".equals(scheme) ? 443 : 80;
}
}
else
{
final int colon = authority.lastIndexOf(':');
if (colon != -1)
{
host = authority.substring(0, colon);
port = parseInt(authority.substring(colon + 1));
}
else
{
host = authority;
port = "https".equals(scheme) ? 443 : 80;
}
}

extension = proxyBeginExRW.wrap(extBuffer, 0, extBuffer.capacity())
.typeId(proxyTypeId)
.address(a -> a.inet(i -> i.protocol(p -> p.set(STREAM))
.source("0.0.0.0")
.destination(host)
.sourcePort(0)
.destinationPort(port)))
.infos(ii -> ii.item(i -> i.authority(authority)))
.build();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If authority != null, then authority follows the pattern host:port where port is always present. This is the canonical internal format we use to avoid reimplementing port defaulting at each component consuming HttpBeginEx.

Reuse a pattern matcher initially created on "" and reset to current authority to pull out named groups for host and port, where port matches as digits so parsing as an integer will never throw an exception.

Pattern should be simple. not-colon, colon, digits.

claude added 3 commits April 21, 2026 01:29
…ring conversion

Address PR #1713 review feedback: keep each header matchFirst call adjacent
to its asString conversion instead of batching the two lookups before both
conversions, and break the long IPv6 authority condition across two lines to
satisfy the 130-character line limit.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
…med host/port groups

Address PR #1713 review feedback: the :authority header on HttpBeginEx is the
canonical internal host:port form with port always present, so reuse a
pre-compiled pattern matcher (reset against the current authority) to extract
named host/port groups instead of reimplementing port defaulting and IPv6
bracket handling at each consumer.

Drop the now-unused scheme parameter from doNetworkBegin and the HEADER_SCHEME
extraction from onRequestBegin.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
…thority

Address PR #1713 review feedback: augment the request.response.and.abort
network scripts so the peer accepting the HTTP client binding's outbound
connection on zilla://streams/net0 asserts the BEGIN extension carries a
ProxyBeginEx with host, port, and authority derived from the application
:authority header, and the matching write on the peer-to-peer NetworkIT
keeps the two scripts self-consistent.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
Copy link
Copy Markdown
Contributor Author

@jfallows jfallows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

claude added 3 commits April 21, 2026 02:54
… in default client routes

When a TCP client has explicit options.host configured and uses
DEFAULT_CLIENT_ROUTES, resolve to the configured host/port rather than
the incoming ProxyBeginEx address.destination/destinationPort. This
restores the pre-#1713 behavior for downstream TCP clients whose target
is config-driven, while still allowing a TCP client with no options to
derive the destination from beginEx (e.g., Kafka broker resolution).

Addresses CI regressions on proxy-style examples (http.proxy,
openapi.proxy, grpc.proxy, sse.proxy.jwt, asyncapi.sse.proxy,
http.json.schema, http.kafka.avro.json) introduced when HttpClientFactory
started emitting ProxyBeginEx.address.inet derived from :authority.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
…:port

TLS client uses ProxyBeginEx AUTHORITY info verbatim as the SNI hostname
(TlsBindingConfig.newClientEngine L217-222). Passing the raw :authority
header value (e.g., "localhost:7143") produces an invalid SNIHostName and
breaks the handshake for pipelines like HTTP client → TLS client → TCP
client (e.g., examples/http.proxy).

Match the codebase convention (see TlsClientFactory L1178 which emits
hostname-only authority) by passing the parsed host, not the full
:authority header value. The port is still carried on address.destinationPort.

Updates the paired request.response.and.abort spec scripts to assert the
host-only authority to keep the peer-to-peer IT self-consistent.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
…i unset

Align client-side SNI precedence with the sibling ALPN logic
(TlsBindingConfig.newClientEngine): an explicitly configured
options.sni should win over a dynamically-derived SNI from a
ProxyBeginEx AUTHORITY info; fall back to the AUTHORITY info only
when options.sni is null.

Previously the AUTHORITY info unconditionally overrode configured
options.sni. This was a latent bug that broke proxy-style examples
(e.g. examples/http.proxy) as soon as an upstream binding started
emitting ProxyBeginEx with AUTHORITY info (HTTP client derivation
from :authority introduced by #1676): the TLS client would replace
its configured SNI (e.g. [nginx]) with the client's request authority
(e.g. [localhost]), presenting the wrong SNI to the upstream server
and failing the handshake.

https://claude.ai/code/session_01GS1do8LS5Av6wrFTTduMVK
@jfallows jfallows requested a review from akrambek April 21, 2026 16:17
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.

binding-http: derive ProxyBeginEx from :authority in HttpBeginEx for stream-driven destination routing

2 participants