Raxol.Core.Outbound.Response (Raxol Core v2.8.0)

Copy Markdown View Source

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.

t()

: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

headers()

@type headers() :: [{String.t(), String.t()}]

Header names are lowercase, as Mint delivers them.

mint_response()

@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.

reason()

@type reason() :: {:too_large, non_neg_integer()} | {:transport, term()}

response()

@type response() :: %{
  status: status(),
  headers: headers(),
  trailers: headers(),
  body: binary()
}

status()

@type status() :: non_neg_integer()

An HTTP status code.

t()

@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

absorb(acc, ref, responses, max_bytes)

@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}}

new()

@spec new() :: t()

A fresh, incomplete accumulator.

to_response(acc)

@spec to_response(t()) :: response()

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.