AudioProxy.ErrorJSON (audio_proxy v0.7.0)

Copy Markdown View Source

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_timeout

The 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 it

It 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

Types

What plugs and actions hand to render/1.

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

structured()

@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

cache_control(status)

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

class(reason)

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

halt_with(conn, error)

@spec halt_with(Plug.Conn.t(), structured()) :: Plug.Conn.t()

Sends the rendered error and halts the conn — the only call a plug needs.

not_found_reasons()

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

render(error)

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

rows()

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