The §5 error contract as one table: structured error in, response out.
Plugs and actions never build their own error responses. They return
{:error, reason} values — a bare atom, a %AudioProxy.OptionError{}, or a
{:queue_full, retry_after} tuple — and this module renders the
{status, headers, body} triple for it. One table, keyed by error class, so
the mapping is reviewable against docs/audio-proxy-api-v1.md §5 as a whole
and a producer landing later (429 with the render semaphore) touches nothing
here — the streaming action, which produces 415/500/504, did not.
The rows, one per §5 line:
error status body `error` extra headers
:invalid_signature 401 invalid_signature
source failure (see below) 404 not_found
:source_too_large 413 source_too_large
:undecodable_source 415 undecodable_source
:video_source 415 video_source
:expired 410 expired
{:range_not_satisfiable, size} 416 range_not_satisfiable Content-Range
%OptionError{} 422 invalid_options
{:queue_full, retry_after} 429 queue_full Retry-After
(queue full, or a wait for a
slot that ran out of budget)
:render_failed 500 render_failed
:probe_failed 500 probe_failed
:not_configured 500 not_configured
:upstream_unavailable 502 upstream_unavailable
:render_timeout 504 render_timeout
:probe_timeout 504 probe_timeoutThe 416 row belongs to the variant cache: only a stored variant has a known
size and therefore a range that can be refused. A Range on anything still
rendering is ignored, per §5, so this status is unreachable from the render
path.
The 500 and 502 rows are this table's additions to §5's, and both are
deliberate: §5 enumerates what a client can have got wrong, and neither a
failed render nor an unreachable store is that. A render that fails for none
of §5's reasons — no ffmpeg on PATH, a full disk, a diagnostic no
classifier recognises — still has to answer something, and a
plausible-looking 4xx would tell the client to stop retrying something that
might well work next time.
The 502 row is not the 404 row
:upstream_unavailable is what a storage backend reports when it could not
reach its store at all — a transport failure, or a 5xx from S3. It is the
one source-side failure that is not the blind 404 below, and the
distinction is the whole point: an outage says nothing about whether the
object exists, so answering 404 reports a deletion that did not happen and
then edge-caches that report for ten seconds, suppressing the retry that
would have worked. 502 says the three things that are true — not the
client's fault, the resource may well exist, retrying is reasonable — and
carries no-store with the rest of the transient rows.
:not_configured sits beside it at 500 for the same honesty: a backend with
no credentials is an operator fault no client action can resolve, and it is
neither a failed render nor a failed probe, so it does not borrow their
bodies and send an operator to the wrong variable.
render/1 is the pure mapping — that is what the per-row unit tests pin —
and halt_with/2 sends it and halts, which is all a plug ever calls.
Every error declares its cacheability
halt_with/2 also sets a Cache-Control derived from the status, replacing
a framework default with a stated policy.
The default it replaces is Plug.Conn's: a response that sets no
cache-control still sends max-age=0, private, must-revalidate. So this
is not filling a silence — it is a deliberate relaxation, dropping
private so the edge may share an error and trading max-age=0 for a short
per-class TTL so a repeated failure costs the origin once rather than every
time. Sound here because an error body is a pure function of the URL: no
cookies, no auth headers, nothing per-user to leak into a shared cache.
404, 413, 415 max-age=10 verdicts about the current source bytes;
a re-upload changes them, and 10 s is the
window a just-uploaded source waits
401, 422 max-age=60 pure functions of the URL — a bad
signature never becomes good, invalid
options never become valid; only a deploy
changes that, and 60 s bounds the window
410 a year, the one verdict that is permanent by
immutable construction: an expired URL cannot become
valid again, because the timestamp it is
judged against is inside the signature. No
deploy changes it either, so unlike 401 and
422 there is nothing for a short TTL to
bound — and an edge answering it outright
is exactly the enforcement wanted
416 no-store the only response here whose body depends
on a request *header*; a shared cache
without `Vary: Range` would hand it to a
client that asked for a range the variant
can satisfy
429, 5xx no-store transient; caching a transient failure
amplifies itIt lives here and not in render/1's row headers so the pure mapping keeps
pinning what is specific to each row; cacheability is a property of the
status class, and one derivation cannot drift row by row.
class/1 reads the same table for its label rather than its response:
halt_with/2 assigns the result to :error_class, which is how the
request log line names what went wrong (AudioProxy.LogHandler). Deriving
it here rather than in the handler is what keeps the log and the response
body saying the same word — the body's error field is the class.
The 404 row is deliberately blind
Every source failure is the same 404, byte for byte: an unauthorized source, a missing file, an unparseable encoding, an unknown scheme. §5 has no 403, and a distinguishable response would turn the source policy into an existence oracle for whatever sits behind it.
A source type added later must extend @source_not_found — exposed as
not_found_reasons/0, so tests exercise the module's own list rather than
a copy that can drift — with its own "not there" reasons, as part of its
slice. An unlisted reason does not become a wrong response: render/1 has
no clause for it and raises FunctionClauseError, so the mistake crashes
that slice's end-to-end tests instead of answering a plausible status in
production. There is deliberately no catch-all: an unlisted reason is a
programmer error, and a default row would hide it.
Summary
Functions
The Cache-Control an error status carries — see the moduledoc table.
Names the error class — the same word the response body's error field
carries.
Sends the rendered error and halts the conn — the only call a plug needs.
The error reasons that render as the generic, byte-identical 404.
Maps a structured error to its {status, headers, body} triple.
The error contract as data: one {status, error} pair per row of the table.
Types
@type structured() :: AudioProxy.OptionError.t() | atom() | {:queue_full, non_neg_integer()} | {:range_not_satisfiable, non_neg_integer()}
What plugs and actions hand to render/1.
Bare atoms for the data-less rows, %AudioProxy.OptionError{} for 422, and
{:queue_full, retry_after_seconds} for 429 — the shape the render
semaphore slice will produce, carrying the queue's own estimate so this
module never invents a Retry-After value.
Functions
@spec cache_control(pos_integer()) :: String.t()
The Cache-Control an error status carries — see the moduledoc table.
Public for the same reason not_found_reasons/0 is: tests and the router's
own unmatched-route 404 read this derivation rather than a copy of it.
@spec class(structured()) :: atom()
Names the error class — the same word the response body's error field
carries.
One clause per row, and no catch-all, for the reason the moduledoc gives: an unlisted reason should crash its own slice's tests rather than get a plausible label in production.
@spec halt_with(Plug.Conn.t(), structured()) :: Plug.Conn.t()
Sends the rendered error and halts the conn — the only call a plug needs.
@spec not_found_reasons() :: [atom()]
The error reasons that render as the generic, byte-identical 404.
Public so tests and future source types exercise this module's own list — a hand-copied list elsewhere would drift silently. Extend it, in the same change, whenever a source type gains a new "not there" reason.
@spec render(structured()) :: {status :: pos_integer(), headers :: [{String.t(), String.t()}], body :: String.t()}
Maps a structured error to its {status, headers, body} triple.
headers covers the rows that carry more than a body — today only 429's
Retry-After. body is the encoded JSON, so tests can pin it byte for
byte.
@spec rows() :: [{pos_integer(), String.t()}]
The error contract as data: one {status, error} pair per row of the table.
Public for the same reason not_found_reasons/0 is — so a check of what the
documentation claims runs against this module's own mapping rather than a
copy of it. AudioProxy.LlmsDocsTest compares it with the error table in
llms-full.txt, both directions.