The bounded response accumulator: a tagged state that starts incomplete.
This is the response half of the outbound policy Raxol.Core.Outbound
states, which is why it carries that name. vet/2 owns rules 1 to 3 (which
target may be reached, and by which addresses) and says outright that rules 4
and 5 belong to whoever owns the request. This module is the part of rule 5 a
request owner cannot write differently per package without the answer
differing too: the size ceiling, the content-length pre-rejection, and the
rule that a read which never terminated has no response at all.
Two packages had a byte-for-byte copy of it, one a submodule of
Raxol.Web3.Exchange and one a private map inside
Raxol.MCP.Client.Transport.Http.Exchange, because the package graph forbids
either depending on the other. raxol_core is what both already depend on,
so this is where the single copy belongs.
Truncation is never a success
:state is :incomplete until the terminating response promotes it, and
to_response/1 accepts nothing else. That is why the accumulator is
a tagged struct rather than a bare map: every halt-based reader in this
ecosystem returns success on abort (Finch.stream_while/5 answers {:ok, acc} for both a finished response and a {:halt, acc}), so an accumulator
whose initial value is shaped like a success turns a refused oversized
response into an empty 200. For a list endpoint that is indistinguishable
from a list with no entries. Here a caller cannot build a response out of an
accumulator the terminating response never reached.
Trailers are not headers
Mint delivers a chunked trailer section as a SECOND {:headers, ref, _}, so
the two are the same tuple and only arrival order tells them apart. They are
collected into :trailers, never merged into :headers, and never used for
the content-length pre-rejection. A merged trailer is a header the response
never sent, arriving after the point every downstream reader assumed the
header set was closed, and a trailer content-length re-runs a size check on
a body that has already been read.
What it does not own
The ceiling arrives as an argument, so this module holds no policy and has no
defaults of its own. It opens no socket, runs no timer, and calls nothing
from Mint: it pattern-matches Mint's response tuples and never invokes it, so
raxol_core gains no dependency. The dial, the wall-clock deadline, the
per-recv chunk timeout and each package's error taxonomy stay with the
request owner, because those differ between the two.
The two reasons that can be decided from the response bytes alone are
returned here: {:too_large, max_bytes} and {:transport, reason}. Both
taxonomies already contain exactly those shapes.
Summary
Types
Header names are lowercase, as Mint delivers them.
One of Mint's response tuples: {:status, ref, status},
{:headers, ref, headers}, {:data, ref, binary}, {:done, ref} or
{:error, ref, reason}. Matched structurally, never constructed.
An HTTP status code.
:headers_seen? is what separates a header block from a trailer block.
Functions
Fold Mint responses in, enforcing the size ceiling as they arrive.
A fresh, incomplete accumulator.
The response, or a raise.
Types
Header names are lowercase, as Mint delivers them.
@type mint_response() :: tuple()
One of Mint's response tuples: {:status, ref, status},
{:headers, ref, headers}, {:data, ref, binary}, {:done, ref} or
{:error, ref, reason}. Matched structurally, never constructed.
@type reason() :: {:too_large, non_neg_integer()} | {:transport, term()}
@type status() :: non_neg_integer()
An HTTP status code.
@type t() :: %Raxol.Core.Outbound.Response{ chunks: [binary()], headers: headers(), headers_seen?: boolean(), size: non_neg_integer(), state: :incomplete | :complete, status: status() | nil, trailers: headers() }
:headers_seen? is what separates a header block from a trailer block.
Mint delivers trailers as a SECOND {:headers, ref, _} and nothing in the
tuple says which one it is, so arrival order is the only evidence there is.
Functions
@spec absorb(t(), reference(), [mint_response()], non_neg_integer()) :: {:incomplete, t()} | {:complete, t()} | {:error, reason()}
Fold Mint responses in, enforcing the size ceiling as they arrive.
Returns {:incomplete, acc}, {:complete, acc}, or {:error, reason},
where reason is already one of the two the response bytes can decide.
Folding stops at the first terminating or refusing response, so responses
after a {:done, ref} are not consumed; one request per connection and no
pipelining means there are none.
iex> alias Raxol.Core.Outbound.Response
iex> ref = make_ref()
iex> Response.absorb(Response.new(), ref, [{:data, ref, "0123456789"}], 4)
{:error, {:too_large, 4}}
iex> alias Raxol.Core.Outbound.Response
iex> ref = make_ref()
iex> headers = [{"content-length", "9000"}]
iex> Response.absorb(Response.new(), ref, [{:headers, ref, headers}], 1024)
{:error, {:too_large, 1024}}
@spec new() :: t()
A fresh, incomplete accumulator.
The response, or a raise.
Only a :complete accumulator has one. A raise rather than an error tuple
because reaching here with an incomplete accumulator is a bug in the read
loop, not an outcome a caller can handle.