Fix HTTP/1 handling of 1xx informational responses#479
Open
Conversation
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.
Coverage Report for CI Build 0Coverage increased (+0.04%) to 87.813%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - 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.
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.
Summary
1xx informational responses (100 Continue, 103 Early Hints, etc.) were
bundled with
HEAD/204/304 inmessage_body/1, so after parsing the 1xxMint emitted
{:done, ref}and popped the request from the queue. Thereal final response arriving afterwards had no active request and Mint
returned
{:error, %HTTPError{reason: {:unexpected_data, _}}}, closingthe connection.
This broke two common scenarios:
Expect: 100-continue, where the server sends 100Continue before the final response.
(
Plug.Conn.inform/3, HAProxy 102 Processing, CDNs sending 103 EarlyHints, etc.).
Aligns HTTP/1 with the documented contract
Mint.HTTP.stream/2already documents the expected behavior for 1xx:HTTP/2 implements this (
lib/mint/http2.ex:1627-1659, tested intest/mint/http2/conn_test.exs:989underdescribe "interim responses (1xx)"),but HTTP/1 did not — callers never saw the final
:statusbecause therequest 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
:informationalbody kind inmessage_body/1. Indecode_body/5, the:informationalclause resets the request'sresponse-side fields (
version,status,headers_buffer, etc.) totheir initial state and continues parsing from
:statuswithout poppingthe request. The
{:status, ref, 1xx}and{:headers, ref, _}responsesare still emitted by the existing
:status/:headersdecode stages, soinformational responses remain visible to the caller; only the premature
{:done, ref}is suppressed.Test plan
Added four
Mint.HTTP1conn tests covering: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.