One request and one bounded response on a dialled connection.
ADR-0038 decision 4 and ADR-0037 decision 5 describe the same read loop,
and this is it, once. Nothing below this module bounds anything: Mint
caps a header section at 256 KiB and nothing else, and there is no
response-size ceiling, no overall deadline and no truncation signal
anywhere in the stack. So the read loop is ours, and it owns four bounds.
The four bounds
- Size. A byte counter over the accumulated body, refusing at the
ceiling (
:max_bytes, default 2 MiB). Exactly the ceiling is allowed; one byte past it is{:too_large, limit}. - Content-length. When the header is present and exceeds the ceiling, the response is refused before a single body byte is read, so an upstream that announces 3 MB costs one round trip rather than the ceiling.
- A wall-clock deadline (
:deadline_ms, default 20 s), computed once at entry so the request send counts against it, and re-checked before everyrecv. A caller that dials first passes the REMAINING budget:Raxol.MCP.Client.Transport.Http.Exchangecomputes the deadline before its connect, so the dial counts against it too. - Per-
recvsilence (:chunk_timeout_ms, default 10 s), which bounds a peer that stops talking mid-response.
Each recv is given min(remaining_deadline, chunk_timeout), which is what
makes the two timeouts distinguishable rather than merged: a timeout on a
clamped wait is {:timeout, :deadline}, a timeout on a full chunk wait is
{:timeout, :chunk}. Total overshoot past the deadline is bounded by one
chunk timeout, so the worst case is deadline_ms + chunk_timeout_ms.
Every reason is an atom
A reason returned from here is {:too_large, limit},
{:timeout, :chunk | :deadline} or {:transport, atom()} -- never a raw
Mint struct. Mint's {:invalid_header_value, name, value} carries the
offending header VALUE, and this module's callers log their reasons.
Why the loop, and not a stream callback
This is the shape Finch implements for :request_timeout, a budget
decremented by each recv and re-checked before the next
(deps/finch/lib/finch/http1/conn.ex:298-301, :313-323). Owning the loop
is what makes the deadline cover the HEADER phase. A deadline checked inside
a stream callback cannot: with Mint's default stream_headers: false no
callback runs until the whole header section is parsed
(deps/mint/lib/mint/http1.ex:159-162), so a peer dribbling header bytes
just inside the chunk timeout is bounded only by the 256 KiB
:max_header_list_size (:154-157), which at one byte per nine seconds is
not a bound. ADR-0038's earlier draft chose the callback; this is the
correction.
Truncation is never a success
The accumulator is Raxol.Core.Outbound.Response: it starts :incomplete
and is promoted to :complete only by the terminating response, so
run/3 cannot return {:ok, _} for anything but a complete read, by
construction. The reasoning for the tagged state is in that module.
The connection
Always closed, on every path. There is no pool (ADR-0038 decision 3), so a connection outlives nothing and leaving one open would leak a socket per request rather than save a handshake.
Why this module lives in raxol_mcp
Both HTTP clients in this repository need exactly this loop:
Raxol.Web3.Exchange for the REST backends and
Raxol.MCP.Client.Transport.Http.Exchange for the remote MCP transport.
Two copies is what the ratchet catches, and the two copies were identical
down to the taxonomy, so there was nothing to average.
It cannot live in raxol_core beside Raxol.Core.Outbound.vet/2 and the
accumulator, because it calls Mint.HTTP and raxol_core carries no
dependencies at all. It can live here: mint is already an OPTIONAL
dependency of this package, raxol_web3 already depends on raxol_mcp
outright for Raxol.MCP.CircuitBreaker (ADR-0033 decision 2), and the
dependency runs that way and not the other. The dial stays with each
caller, since one pins vetted addresses for a remote MCP server and the
other is Raxol.Web3.Dial; so does each caller's connect-side taxonomy.
Like every other module here that touches Mint, it is compiled only when
mint is available, which is why Raxol.MCP.Client.Transport.select/1
checks for it before handing out the HTTP transport.
Summary
Functions
The default wall-clock deadline, in milliseconds.
Send request on conn and read the response under the four bounds.
Types
@type reason() :: {:too_large, pos_integer()} | {:timeout, :chunk | :deadline} | {:transport, atom()}
@type request() :: %{ :method => String.t(), :path => String.t(), optional(:headers) => Mint.Types.headers(), optional(:body) => iodata() | nil }
@type response() :: Raxol.Core.Outbound.Response.response()
Functions
@spec default_deadline_ms() :: pos_integer()
The default wall-clock deadline, in milliseconds.
Public because a caller that dials before calling run/3 starts this
clock itself, and it has to be the same number.
@spec run(Mint.HTTP.t(), request(), keyword()) :: {:ok, response()} | {:error, reason()}
Send request on conn and read the response under the four bounds.
The connection is closed before returning, whatever the outcome.
Options: :max_bytes, :deadline_ms, :chunk_timeout_ms.