Decision records

ADR-0045: Experimental own HTTP/1 upstream client (opt-in)

Status: accepted as an opt-in experiment — 2026-10-09

Context

The proxy is the one workload where scalws stays behind nginx (0.81–0.87×). Profiling put the difference in user space (17 µs vs 5.8 µs per request), part of it in hyper’s client machinery: a dispatcher per connection, a body channel and several allocations per response. An own client without that machinery was written (2026-10-08) and kept out of the build: HTTP/1 parsing and framing must not be hand-written for production — request smuggling, Content-Length/Transfer-Encoding conflicts, chunked encoding and connection reuse are where proxies go wrong. A new client may become a default only if it is faster on real applications too, not just in synthetic tests.

Decision

  • server.upstream_client: hyper | experimental (default hyper, read at start-up) and, per proxy route, client: overriding it. The hyper path (inline.rs) is unchanged; the experimental client is a separate module (own_client.rs, h1.rs) with the same pooling, replay rules and upstream telemetry (ADR-0043).
  • Response heads are parsed by httparse; framing follows RFC 9112 §6.3 strictly (conflicting or malformed Content-Length rejected, TE overrides CL and the connection is then closed, chunked with CRLF only and bounded lines/trailers, no bytes allowed between responses on an idle connection).
  • Validation against hyper, not only against the RFC:
    • every proxy unit test runs against both clients;
    • a differential test proxies generated upstream responses (interim 1xx, chunk extensions and trailers, TE with CL, close-delimited bodies, HEAD/204/304, malformed framing) through both clients and requires identical status, headers, body and number of upstream connections — 400 cases per test run, 20 000 run once;
    • fuzz targets upstream_head and chunked (decoding must not depend on how the input is split).
  • It does not become the default without real-application measurements in its favour.

Findings during validation

  • Differential test: a bodyless response (HEAD) with both Transfer-Encoding and Content-Length kept its connection with the own client while hyper’s path closed it. Fixed to close (§6.1).
  • Fuzzing: the same response kept the stale Content-Length in the parsed head (the proxy removed it before the client saw it). Fixed.

Measurements

See docs/benchmarks/2026-10-09-experimental-upstream-client.md.