lib/audio_proxy/error_json.ex
Rama main · réplica de lectura del repositorio original.
defmodule AudioProxy.ErrorJSON do
@moduledoc """
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.
"""
import Plug.Conn
alias AudioProxy.OptionError
@typedoc """
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.
"""
@type structured ::
OptionError.t()
| atom()
| {:queue_full, non_neg_integer()}
| {:range_not_satisfiable, non_neg_integer()}
# Reasons that all mean "no servable source": the shared resolver's
# rejections (`AudioProxy.Source`), each source type's own, and the storage
# seam's verdicts (`authorize/1`, `stat/1`, `ffmpeg_input/1`).
#
# `:no_backend` is the one worth a word. A remote source whose backend has
# not shipped yet is, from a client's side of the wire, exactly "not one this
# proxy may serve" — §5's own words for the 404 row — so it answers the same
# blind 404 as everything else here rather than a 5xx blaming the operator
# for a URL that names nothing servable.
@source_not_found [
:unknown_encoding,
:invalid_encoding,
:malformed_escape,
:empty_source,
:control_character,
:unknown_scheme,
:empty_path,
:missing_bucket,
:missing_key,
:invalid_url,
:userinfo_not_allowed,
:source_too_long,
:no_backend,
:not_allowed,
:not_found
]
@doc """
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 not_found_reasons() :: [atom()]
def not_found_reasons, do: @source_not_found
# One representative error per row of the moduledoc table — the input a
# caller would hand `render/1` to produce that row. It exists so `rows/0`
# can *derive* the table rather than restate it. The 404 row takes the first
# source reason, since every one of them renders byte-identically.
#
# It has to grow with `render/1`, and neither direction is self-enforcing.
# A row here without a clause below crashes `rows/0` loudly. A clause below
# without a row here does *not* — `rows/0` would simply omit it, and the
# llms.txt drift guard would then compare the document against an
# incomplete set and pass. `AudioProxy.ErrorJSONTest` closes that direction
# by counting the clauses; without it "the docs cannot drift" would be true
# only of the errors somebody remembered to list.
@representative_errors [
:invalid_signature,
hd(@source_not_found),
:source_too_large,
:undecodable_source,
:video_source,
:expired,
{:range_not_satisfiable, 0},
%OptionError{segment: "f:xyz", reason: :invalid_value},
{:queue_full, 1},
:render_failed,
:probe_failed,
:not_configured,
:upstream_unavailable,
:render_timeout,
:probe_timeout
]
@doc """
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.
"""
@spec rows() :: [{pos_integer(), String.t()}]
def rows do
Enum.map(@representative_errors, fn error ->
{status, _headers, body} = render(error)
{status, JSON.decode!(body)["error"]}
end)
end
@doc """
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 render(structured()) ::
{status :: pos_integer(), headers :: [{String.t(), String.t()}], body :: String.t()}
# `Content-Range: bytes */size` is what tells the client the size it should
# have asked within — RFC 9110 §14.4 requires it on a 416.
def render({:range_not_satisfiable, size}) when is_integer(size) and size >= 0 do
body = encode(%{error: "range_not_satisfiable", message: "Range not satisfiable"})
{416, [{"content-range", "bytes */#{size}"}], body}
end
def render(%OptionError{} = error) do
{422, [], encode(%{error: "invalid_options", message: OptionError.message(error)})}
end
def render(:invalid_signature) do
{401, [], encode(%{error: "invalid_signature", message: "Invalid or missing signature"})}
end
def render(reason) when reason in @source_not_found do
{404, [], encode(%{error: "not_found", message: "Source not found"})}
end
def render(:source_too_large) do
{413, [], encode(%{error: "source_too_large", message: "Source exceeds AP_MAX_SRC_BYTES"})}
end
def render(:undecodable_source) do
{415, [], encode(%{error: "undecodable_source", message: "Source format is not decodable"})}
end
# Its own row rather than a second message on `:undecodable_source`: the
# source decodes perfectly well, and a client told "not decodable" about a
# file every player opens would go looking for a corrupt upload. This is a
# policy refusal, and the body says so.
def render(:video_source) do
{415, [],
encode(%{
error: "video_source",
message: "Source contains video; this proxy serves audio only"
})}
end
# 410 rather than 401: the signature is *valid*, and saying otherwise would
# send a client looking for a key problem it does not have. Gone is also the
# honest tense — this URL worked, and will not again.
def render(:expired) do
{410, [], encode(%{error: "expired", message: "URL has expired"})}
end
def render({:queue_full, retry_after})
when is_integer(retry_after) and retry_after >= 0 do
body = encode(%{error: "queue_full", message: "Render queue is full"})
{429, [{"retry-after", Integer.to_string(retry_after)}], body}
end
def render(:render_failed) do
{500, [], encode(%{error: "render_failed", message: "Render failed"})}
end
def render(:render_timeout) do
{504, [], encode(%{error: "render_timeout", message: "Render exceeded AP_RENDER_TIMEOUT"})}
end
# The probe pair. Separate rows rather than reusing the render ones because
# the body names the limit an operator would raise, and `AP_RENDER_TIMEOUT`
# is not that limit for `/info` — the two budgets are configured apart, and
# an error that named the wrong one would send them to the wrong variable.
def render(:probe_failed) do
{500, [], encode(%{error: "probe_failed", message: "Probe failed"})}
end
def render(:probe_timeout) do
{504, [], encode(%{error: "probe_timeout", message: "Probe exceeded AP_PROBE_TIMEOUT"})}
end
# The storage seam's own two rows. Neither is a source failure, so neither
# is the blind 404 — see the moduledoc.
def render(:not_configured) do
body = encode(%{error: "not_configured", message: "Storage backend is not configured"})
{500, [], body}
end
def render(:upstream_unavailable) do
body = encode(%{error: "upstream_unavailable", message: "Storage backend is unavailable"})
{502, [], body}
end
@doc """
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 class(structured()) :: atom()
def class(%OptionError{}), do: :invalid_options
def class(reason) when reason in @source_not_found, do: :not_found
def class({:queue_full, _retry_after}), do: :queue_full
def class({:range_not_satisfiable, _size}), do: :range_not_satisfiable
def class(:invalid_signature), do: :invalid_signature
def class(:source_too_large), do: :source_too_large
def class(:undecodable_source), do: :undecodable_source
def class(:video_source), do: :video_source
def class(:expired), do: :expired
def class(:render_failed), do: :render_failed
def class(:render_timeout), do: :render_timeout
def class(:probe_failed), do: :probe_failed
def class(:probe_timeout), do: :probe_timeout
def class(:not_configured), do: :not_configured
def class(:upstream_unavailable), do: :upstream_unavailable
@doc """
Sends the rendered error and halts the conn — the only call a plug needs.
"""
@spec halt_with(Plug.Conn.t(), structured()) :: Plug.Conn.t()
def halt_with(conn, error) do
{status, headers, body} = render(error)
conn
|> assign(:error_class, class(error))
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", cache_control(status))
|> then(fn conn ->
Enum.reduce(headers, conn, fn {k, v}, c -> put_resp_header(c, k, v) end)
end)
|> send_resp(status, body)
|> halt()
end
@doc """
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 cache_control(pos_integer()) :: String.t()
def cache_control(status) when status in [404, 413, 415], do: "max-age=10"
def cache_control(status) when status in [401, 422], do: "max-age=60"
def cache_control(410), do: "public, max-age=31536000, immutable"
def cache_control(status) when status in [416, 429] or status >= 500, do: "no-store"
defp encode(map), do: JSON.encode!(map)
end