Skip to content

Fix HTTP/1 handling of 1xx informational responses#479

Open
ericmj wants to merge 1 commit intomainfrom
ericmj/fix-1xx-informational-response
Open

Fix HTTP/1 handling of 1xx informational responses#479
ericmj wants to merge 1 commit intomainfrom
ericmj/fix-1xx-informational-response

Conversation

@ericmj
Copy link
Copy Markdown
Member

@ericmj ericmj commented Apr 12, 2026

Summary

1xx informational responses (100 Continue, 103 Early Hints, etc.) were
bundled with HEAD/204/304 in message_body/1, so after parsing the 1xx
Mint emitted {:done, ref} and popped the request from the queue. The
real final response arriving afterwards had no active request and Mint
returned {:error, %HTTPError{reason: {:unexpected_data, _}}}, closing
the connection.

This broke two common scenarios:

  • Requests sent with Expect: 100-continue, where the server sends 100
    Continue before the final response.
  • Servers or intermediaries emitting unsolicited 1xx responses
    (Plug.Conn.inform/3, HAProxy 102 Processing, CDNs sending 103 Early
    Hints, etc.).

Aligns HTTP/1 with the documented contract

Mint.HTTP.stream/2 already documents the expected behavior for 1xx:

{:status, request_ref, status_code} — You can have zero or more 1xx
:status and :headers responses for a single request, but they all
precede a single non-1xx :status response.

HTTP/2 implements this (lib/mint/http2.ex:1627-1659, tested in
test/mint/http2/conn_test.exs:989 under describe "interim responses (1xx)"),
but HTTP/1 did not — callers never saw the final :status because the
request was popped after the 1xx. This PR brings HTTP/1 behavior in line
with both the documented contract and the HTTP/2 implementation, so a
single handler works for both protocols.

Fix

Split 1xx into a new :informational body kind in message_body/1. In
decode_body/5, the :informational clause resets the request's
response-side fields (version, status, headers_buffer, etc.) to
their initial state and continues parsing from :status without popping
the request. The {:status, ref, 1xx} and {:headers, ref, _} responses
are still emitted by the existing :status/:headers decode stages, so
informational responses remain visible to the caller; only the premature
{:done, ref} is suppressed.

Test plan

Added four Mint.HTTP1 conn tests covering:

  • 100 Continue followed by a final response
  • 103 Early Hints followed by a final response
  • Multiple informational responses before the final response
  • Informational response split across multiple TCP messages

All existing HTTP/1 unit tests continue to pass (67/67). The same 17
pre-existing network-dependent integration failures occur with and without
this change — no regressions.

1xx informational responses (100 Continue, 103 Early Hints, etc.) were
treated the same as HEAD and 204/304 responses: Mint emitted `{:done, ref}`
and popped the request from its queue. The real final response arriving
afterwards had no active request and Mint returned `{:unexpected_data, _}`,
closing the connection.

This broke two common scenarios:

  * Requests sent with `Expect: 100-continue`, where the server sends 100
    Continue before the final response.
  * Servers or intermediaries emitting unsolicited 1xx (Plug.Conn.inform/3,
    HAProxy 102 Processing, CDNs sending 103 Early Hints, etc.).

Fix: split 1xx out of the `:none` body branch in `message_body/1` into a
new `:informational` body kind. In `decode_body/5`, the `:informational`
clause resets the request's response-side fields (`version`, `status`,
`headers_buffer`, etc.) back to their initial state and continues parsing
from `:status` without popping the request. The `{:status, ref, 1xx}` and
`{:headers, ref, _}` responses are still emitted to the caller by the
existing `:status`/`:headers` decode stages, so informational responses
remain visible; only the premature `{:done, ref}` is suppressed.
@coveralls
Copy link
Copy Markdown

Coverage Report for CI Build 0

Coverage increased (+0.04%) to 87.813%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 6 of 6 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1477
Covered Lines: 1297
Line Coverage: 87.81%
Coverage Strength: 262.23 hits per line

💛 - Coveralls

ericmj added a commit to hexpm/hex that referenced this pull request Apr 12, 2026
The previous pool design split HTTP/1 and HTTP/2 into separate modules
and probed ALPN in the caller process. That caller opened the socket,
then tried to hand it off to a worker via `controlling_process/2` — which
fails with `:not_owner` when called from anywhere except the current
socket owner. The "fix" in HTTP1.Worker.init/1 (calling
`controlling_process(conn, self())` from the worker) could never have
worked at runtime; the HTTP/2 path had the same latent bug.

Replaced the HTTP1/HTTP1.Worker/HTTP2 modules with a unified design:

  * Hex.HTTP.Pool.Host (one GenServer per {scheme, host, port}) owns a
    pool of Conn processes. On start it spawns two probe Conns; when the
    first reports its protocol it scales up to 8 for :http1 or stays at
    2 for :http2. Dispatches by least in-flight to the Conn with free
    capacity; queues waiters otherwise.
  * Hex.HTTP.Pool.Conn (one GenServer per Mint connection) connects
    inside its own process, so the socket is owned from birth — no
    controlling_process handoff ever needed. Reports negotiated protocol
    and per-conn capacity (1 for HTTP/1, server's max_concurrent_streams
    for HTTP/2) back to its host. Requests arrive as casts carrying the
    caller's `from`; Conn replies directly to the caller and casts back
    :req_done so the host decrements in-flight. Handles GOAWAY by
    draining in-flight then reconnecting with exponential backoff.
  * Hex.HTTP.Pool now just wires up the Registry + DynamicSupervisor and
    routes requests to the Host for a given {scheme, host, port}. No
    more probe phase, no ETS cache, no initial_conn plumbing.

Also fixes 1xx informational response handling in the vendored Mint
HTTP/1 module, which previously emitted `:done` and popped the request
after a 100 Continue / 103 Early Hints etc., causing the real final
response to arrive with no active request and the connection to close.
Upstream fix submitted as elixir-mint/mint#479; the vendored copy marks
the change with `# HEX PATCH` comments for easy re-application after
re-vendoring.

Conn.process_response resets accumulated headers/data on each new
`:status` so informational headers (e.g. 103 Early Hints `link:`) don't
bleed into the final response. Added bypass-backed regression tests for
100 Continue round-trip, 100 Continue with early error response, and
103 Early Hints with headers. test/mix/tasks/hex.registry_test.exs now
uses `Hex.HTTP.config/0` instead of the raw httpc-defaulting config.
ericmj added a commit to hexpm/hex that referenced this pull request Apr 13, 2026
The previous pool design split HTTP/1 and HTTP/2 into separate modules
and probed ALPN in the caller process. That caller opened the socket,
then tried to hand it off to a worker via `controlling_process/2` — which
fails with `:not_owner` when called from anywhere except the current
socket owner. The "fix" in HTTP1.Worker.init/1 (calling
`controlling_process(conn, self())` from the worker) could never have
worked at runtime; the HTTP/2 path had the same latent bug.

Replaced the HTTP1/HTTP1.Worker/HTTP2 modules with a unified design:

  * Hex.HTTP.Pool.Host (one GenServer per {scheme, host, port}) owns a
    pool of Conn processes. On start it spawns two probe Conns; when the
    first reports its protocol it scales up to 8 for :http1 or stays at
    2 for :http2. Dispatches by least in-flight to the Conn with free
    capacity; queues waiters otherwise.
  * Hex.HTTP.Pool.Conn (one GenServer per Mint connection) connects
    inside its own process, so the socket is owned from birth — no
    controlling_process handoff ever needed. Reports negotiated protocol
    and per-conn capacity (1 for HTTP/1, server's max_concurrent_streams
    for HTTP/2) back to its host. Requests arrive as casts carrying the
    caller's `from`; Conn replies directly to the caller and casts back
    :req_done so the host decrements in-flight. Handles GOAWAY by
    draining in-flight then reconnecting with exponential backoff.
  * Hex.HTTP.Pool now just wires up the Registry + DynamicSupervisor and
    routes requests to the Host for a given {scheme, host, port}. No
    more probe phase, no ETS cache, no initial_conn plumbing.

Also fixes 1xx informational response handling in the vendored Mint
HTTP/1 module, which previously emitted `:done` and popped the request
after a 100 Continue / 103 Early Hints etc., causing the real final
response to arrive with no active request and the connection to close.
Upstream fix submitted as elixir-mint/mint#479; the vendored copy marks
the change with `# HEX PATCH` comments for easy re-application after
re-vendoring.

Conn.process_response resets accumulated headers/data on each new
`:status` so informational headers (e.g. 103 Early Hints `link:`) don't
bleed into the final response. Added bypass-backed regression tests for
100 Continue round-trip, 100 Continue with early error response, and
103 Early Hints with headers. test/mix/tasks/hex.registry_test.exs now
uses `Hex.HTTP.config/0` instead of the raw httpc-defaulting config.
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.

2 participants