The pinned dial and the bounded read: one request, one bounded response.
ADR-0037 decision 5, which says explicitly that this transport takes the
shape ADR-0038 decision 3 and 4 already implement for the REST side
(packages/raxol_web3/lib/raxol/web3/dial.ex,
packages/raxol_web3/lib/raxol/web3/exchange.ex). One design in two
packages rather than two: copying is forced by the package graph, since
raxol_web3 depends on raxol_mcp and not the other way round.
The checked address is the address dialled
run/3 takes the :inet address tuples Raxol.Core.Outbound.vet/2
checked, never a name, so there is no code path here that could consult a
resolver and no window for a record that changes between the check and the
connect. A binary where an address belongs is refused as
{:not_an_address, value} rather than resolved.
Mint derives everything that must follow the NAME from one :hostname
option: SNI, the certificate match function, and the HTTP/1 Host header.
So :server_name_indication and :customize_hostname_check are never set
by hand -- setting either replaces a value Mint already derived correctly
-- and :verify and :verify_fun are refused outright, because a silently
weakened handshake is the failure this module exists to prevent. A
caller-supplied :transport_opts never reaches here: the only caller is
Raxol.MCP.Client.Transport.Http, which does not accept one.
There is no connection pool. A pool key is {scheme, host, port, tag}, so
a vetted IP in the host position makes every hostname that resolves to that
address share one pool and inherit whichever identity opened it first.
The bounded read
The send, the four bounds (size ceiling, content-length pre-rejection,
wall-clock deadline, per-recv silence) and the close are
Raxol.MCP.BoundedExchange, which Raxol.Web3.Exchange reads through too:
the two used to be identical copies, down to the read-phase taxonomy. The
reasoning for each bound is in that module. What stays here is the dial and
the connect-side reasons, which are this transport's alone.
Truncation is never a success
The accumulator is Raxol.Core.Outbound.Response: it starts :incomplete
and only the terminating response promotes it, so run/3 cannot return
{:ok, _} for a partial response by construction. That matters more here
than for a REST body: a truncated SSE stream still contains whole frames,
so a reader that returned what it had would hand the session a
plausible-looking response to a request that never completed.
Redirects
A 3xx is returned as a response, not followed, and the caller refuses it.
Following one would replay this transport's Authorization header at an
origin the upstream chose.
Summary
Functions
Connect to the first reachable vetted address, presenting hostname as the
identity.
Dial a vetted target and read one bounded response.
Types
@type reason() :: :no_addresses | {:not_an_address, term()} | {:forbidden_transport_opts, [atom()]} | {:too_large, pos_integer()} | {:timeout, :connect | :chunk | :deadline} | {:transport, term()}
@type request() :: %{ :method => String.t(), :path => String.t(), optional(:headers) => Mint.Types.headers(), optional(:body) => iodata() | nil }
@type response() :: Raxol.MCP.BoundedExchange.response()
@type vetted() :: %{ uri: URI.t(), addresses: [:inet.ip_address()], hostname: String.t() }
Functions
@spec connect(vetted(), keyword()) :: {:ok, Mint.HTTP.t()} | {:error, reason()}
Connect to the first reachable vetted address, presenting hostname as the
identity.
Every address is tried in turn, which is the failover :gen_tcp performs
for a name and which pinning would otherwise lose. Public so that the dial
can be tested against a real local TLS listener, which the reject set
refuses to let the guarded path reach.
:deadline_at (a monotonic millisecond stamp, set by run/3) clamps
every attempt: N addresses at connect_timeout_ms each would otherwise
exceed the whole exchange's budget before a byte was sent.
Dial a vetted target and read one bounded response.
Options: :port (defaults to the vetted URI's port), :connect_timeout_ms,
:deadline_ms, :chunk_timeout_ms, :max_bytes, and :transport_opts
for a test's own trust store. The connection is closed on every path.
The deadline is computed HERE, before the dial, and what reaches
BoundedExchange.run/3 is what is left of it. BoundedExchange's
moduledoc says the deadline is taken at entry so the send counts against
it; starting that clock after the connect made the real wall bound
connect_timeout_ms x addresses + deadline_ms + chunk_timeout_ms, which
was stated nowhere and, at the defaults, exceeded the client's 30 s
call_timeout. Each connect attempt is clamped to the remaining budget
for the same reason, so the bound is deadline_ms plus at most one
chunk_timeout_ms of read overshoot.