RFC 9457 application/problem+json errors for the machine API.
Every error the API sends, from the endpoint's PhoenixKit.API.BodyGuard
to a tool handler's {:error, code, message, context}, goes through
send/4, so a client sees one shape:
{"type": "https://phoenixkit.dev/api/errors/forbidden",
"title": "Forbidden", "status": 403, "code": "forbidden",
"detail": "This key lacks catalogue.prices:write.",
"context": {"required_scope": "catalogue.prices:write"},
"request_id": "F9x..."}code is the contract: stable, machine-readable, and what an agent
branches on. detail is always English prose for a human or an agent to
relay. context carries the fields that make the error actionable
(required_scope, errors, retry_after, ...).
The codes of core are a closed table here (core_code?/1). A module adds its
own through PhoenixKit.Module.api_problems/0 (it can never replace a code of
core), and PhoenixKit.API.Legacy supplies the few codes the API had
before they became a module's, for as long as no enabled module declares them.
Both are read only here, when an error is answered, so a successful call pays
nothing. statuses/0 is the table as it stands right now. A code that is in
none of the three answers 500 and is logged: it means a handler returned a
code nothing declares.
Summary
Functions
The problem document as a map with string keys.
Whether code is one of core's own, which no module can override.
What is declared for a code that core does not define: %{status, title, advice}
from the enabled module that declares it, else from PhoenixKit.API.Legacy, else
nil. A module's declaration replaces the legacy one as a whole.
Sends the problem document for code and halts the connection.
The HTTP status of an error code.
Every error code the API can answer with right now, with its HTTP status: core's,
those of the enabled modules and the PhoenixKit.API.Legacy ones no module
declares.
Functions
The problem document as a map with string keys.
request_id is left out of the map when it is nil.
Whether code is one of core's own, which no module can override.
@spec declared(atom()) :: %{status: pos_integer(), title: String.t(), advice: String.t() | nil} | nil
What is declared for a code that core does not define: %{status, title, advice}
from the enabled module that declares it, else from PhoenixKit.API.Legacy, else
nil. A module's declaration replaces the legacy one as a whole.
@spec send(Plug.Conn.t(), atom(), String.t(), map()) :: Plug.Conn.t()
Sends the problem document for code and halts the connection.
The response carries content-type: application/problem+json and
cache-control: no-store. Three codes add a header: unauthorized a
www-authenticate challenge, forbidden with a required_scope in the
context an insufficient_scope challenge, and any context with a
retry_after (seconds) a retry-after header.
The request id is conn.assigns.phoenix_kit_api_request_id when the
PhoenixKit.API.Auth plug has run, else the x-request-id response
header that Plug.RequestId set. A header value with characters outside
[A-Za-z0-9_=+/-] (Plug.RequestId accepts any client-sent id of 20..200
bytes) is replaced by a fresh id, in the header and in the body, the same
rule Auth applies.
@spec status(atom()) :: pos_integer()
The HTTP status of an error code.
A code that no table declares answers 500.
@spec statuses() :: %{required(atom()) => pos_integer()}
Every error code the API can answer with right now, with its HTTP status: core's,
those of the enabled modules and the PhoenixKit.API.Legacy ones no module
declares.