View Source Errors at a boundary
A boundary — an HTTP handler, a job runner, a message consumer — has to turn an error into a decision: what status to return, what to show the caller, how loudly to log it, whether to try again. Errata answers each of those with a function that works on any error type, so a boundary can be written once and keep working as new error types are added.
Mapping errors to HTTP status codes
At an HTTP boundary you often want to translate an error into a response status.
Every Errata error has a generated http_status/1 function whose default is
derived from the error's kind — :domain errors map to 422, :infrastructure
errors to 503, and :general errors to 500. Set a specific status per type
with the :http_status option, or override http_status/1 to compute one from
the error's reason or context:
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError, http_status: 404
endErrata.http_status/1 returns the status for any Errata error without needing
to know its specific type, which is convenient in a Phoenix fallback controller:
def call(conn, {:error, error}) when Errata.is_error(error) do
conn
|> put_status(Errata.http_status(error))
|> put_view(MyApp.ErrorView)
|> render("error.json", error: error)
endThis keeps Errata free of any web-framework dependency: it hands you the status code, and the framework glue stays in your application. The view this renders through is shown in full under a worked boundary, along with the response bodies it produces.
Errors that aren't Errata errors
The clause above handles the errors your application defined. A real system also
receives errors it did not define: {:error, :timeout} from a client library, an
Ecto.Changeset, a DBConnection.ConnectionError. Those do not satisfy
Errata.is_error/1, and the accessors raise on them rather than guessing —
Errata.http_status(:timeout) is a programming error, not a 500.
Errata.to_error/2 converts any value into an Errata error, so the boundary has
one shape to handle:
iex> error = Errata.to_error(:timeout)
iex> Errata.http_status(error)
500
iex> Errata.cause(error)
:timeoutA value Errata knows nothing about becomes an Errata.UnknownError — a 500
that is not retryable — keeping the original as its cause, so nothing is
discarded on the way through. Errata errors are returned unchanged, which makes
the call safe to apply to something that may already be one.
Pairing to_error/2 with the cause chain like this is the shape a consumer needs
when the error in hand describes a handler's reaction rather than the failure.
Note which end of the chain you want: Errata.cause/1 gives you the foreign
original, which here is the bare :timeout, while Errata.root_error/1 gives
you the deepest thing that still carries a code and a context. See
unwrapping a wrapped error.
Wrapping versus normalizing
Errata.wrap/3 also turns an arbitrary value into an
Errata error, so the two overlap. The difference is whether you know what the
failure means.
Wrapping is an act of interpretation, and belongs where a failure is caught: you name the error type because in that place you know what a dropped connection means for the operation in hand. It always adds a layer, since each layer's interpretation is worth keeping.
Normalizing belongs where an error leaves the system and anything at all can
arrive. There is no type to name, and an error that already is one is returned
untouched. That last part is why reaching for wrap/3 at a boundary is a bug
rather than a style choice — even wrapping in the very type to_error/2 would
have chosen:
iex> require Errata
iex> error = MyApp.Orders.OrderNotFound.new(reason: :not_found)
iex> Errata.http_status(error)
422
iex> Errata.to_error(error) |> Errata.http_status()
422
iex> Errata.wrap(Errata.UnknownError, error) |> Errata.http_status()
500
iex> Errata.wrap(Errata.UnknownError, error) |> Errata.display_message()
"an unexpected error occurred"A 404 becomes a 500, and the message the user was meant to read is replaced
by a generic one.
wrap/3 | to_error/2 | |
|---|---|---|
| do you know what it means? | yes — you name the type | no — it is whatever arrived |
| where | where the failure is caught | where the error leaves |
| given an Errata error | adds a layer | returns it unchanged |
| form | macro; records the call site | function; capturable; no :env |
They compose rather than compete: an error wrapped on its way up keeps its full chain of causes, and normalizing at the edge leaves that chain intact.
Handling {:error, reason} tuples
Tuples are not unwrapped, since a value that legitimately is a two-tuple can't be told apart from one that means "error". Match at the call site instead:
case Repo.transaction(fun) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, Errata.to_error(reason)}
endClassifying the types you recognize
A 500 is the right answer for a genuinely unknown value and the wrong answer
for a changeset, which is a 422, or a connection timeout, which is a retryable
503. Errata.to_error/2 classifies nothing on its own — it is the base case
beneath the types your application recognizes:
defmodule MyApp.Errors do
def to_error(%Ecto.Changeset{} = changeset) do
MyApp.ValidationFailed.new(
reason: :invalid,
context: %{errors: MyApp.Changeset.error_map(changeset)},
cause: changeset
)
end
def to_error(%MyApp.Http.Timeout{url: url} = timeout),
do: MyApp.Http.RequestFailed.new(reason: :timeout, context: %{url: url}, cause: timeout)
# everything else falls through to the library default
def to_error(other), do: Errata.to_error(other)
endEach recognized type gets the classification of the error type it converts to, and anything you have not gotten to yet still arrives as something the boundary can render, log and count:
iex> error = MyApp.Errors.to_error(%MyApp.Http.Timeout{url: "https://example.com"})
iex> Errata.http_status(error)
503
iex> Errata.retryable?(error)
true
iex> MyApp.Errors.to_error(:something_unforeseen) |> Errata.http_status()
500Plain function clauses rather than anything Errata-specific: one function to read to see how a system classifies its errors, and two boundaries can classify the same value differently when they need to — an admin API and a public API rarely want to say the same thing about a changeset.
The fallback controller then loses its hand-written clauses, including the guard:
def call(conn, {:error, error}) do
error = MyApp.Errors.to_error(error)
conn
|> put_status(Errata.http_status(error))
|> put_view(MyApp.ErrorView)
|> render("error.json", error: error)
endNormalize at the boundary, not before it
Errata.http_status(:timeout) raises, and that is worth keeping: inside your
domain code it means an error escaped unclassified, which is a bug you want to
hear about. Normalizing turns that noise into a quiet 500, so call
to_error/2 where an error is on its way out of the system — a controller, a
job runner, a message consumer — rather than in the middle of the code that
produced it.
Stable external error codes
The only type identity to_map/1 exposes is the module name
("MyApp.Orders.OrderNotFound"), which is an implementation detail: rename or
move the module and the identifier your API clients match on changes with it.
For consumers outside your codebase — API clients, i18n catalogs, support
tooling — give the type a stable external code instead:
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError, code: "ORDER_NOT_FOUND"
endThe code appears in to_map/1 (and so in the JSON encoding) under the code
key, and in the metadata emitted by Errata.log/2 and Errata.report/2.
Errata.code/1 returns it for any Errata error.
Codes are entirely opt-in and there is no default — a type that doesn't declare
one returns nil, and Errata deliberately does not derive a code from the module
name, since that would reintroduce the very coupling the code exists to avoid. A
boundary that needs a code for every error should supply its own fallback:
Errata.code(error) || "UNKNOWN"Like the other generated functions, code/1 is overridable, so a single error
type can carry different codes per reason:
defmodule MyApp.Auth.TokenInvalid do
use Errata.DomainError, reasons: [:expired, :revoked]
def code(%{reason: :expired}), do: "TOKEN_EXPIRED"
def code(%{reason: :revoked}), do: "TOKEN_REVOKED"
def code(_error), do: "TOKEN_INVALID"
endClassifying errors: severity and retryability
Two further classifications are available on every error type, both following the
same pattern as http_status/1 — a use option, an overridable per-module
function, and a top-level accessor that works on any Errata error.
Severity (Errata.severity/1) is a Logger level describing how much the
error matters. It defaults to :error for every type, so nothing is reclassified
behind your back; set :severity on the types that deserve a quieter (or
louder) treatment:
defmodule MyApp.Orders.RateLimited do
use Errata.DomainError, severity: :warning
endSeverity is the level Errata.log/2 uses when you don't pass one explicitly, and
it is included in the metadata of both Errata.log/2 and Errata.report/2, so a
telemetry handler can route or alert on it.
Retryability (Errata.retryable?/1) records whether the failure is likely to
be transient. Its default is derived from the error's kind — :infrastructure
errors are retryable (timeouts and connection blips usually are), :domain and
:general errors are not — and it can be set per type or computed per error:
defmodule MyApp.Orders.PaymentGatewayError do
use Errata.InfrastructureError
# a rejected request will be rejected again; a timeout may not be
def retryable?(%{reason: :invalid_request}), do: false
def retryable?(_error), do: true
endErrata deliberately ships no retry mechanism of its own. retryable?/1 is a
classification your own retry logic — or a library such as
ExternalService, which handles retries
with backoff, rate limiting and circuit breakers, and which already uses Errata
for its own errors — can branch on without knowing the error's specific type:
case do_work() do
{:error, error} when Errata.is_error(error) ->
if Errata.retryable?(error), do: retry(), else: {:error, error}
result ->
result
endCarrying the classification across the wire
Everything above works wherever the error struct is in hand. Once an error is serialized — an API response, a job payload, a message on a queue — the receiving side has a plain map, and the module that knows how to classify it may live in another service, or in a program that isn't written in Elixir at all.
So Errata.to_map/1, and therefore the JSON encoding, carries the classification
along with the error:
iex> alias MyApp.Http.RequestFailed
iex> map = Errata.to_map(RequestFailed.new(reason: :timeout))
iex> {map.kind, map.http_status, map.severity, map.retryable}
{:infrastructure, 503, :error, true}These four keys are computed through the same overridable functions as the
accessors, so a type that derives its status or retryability from :reason is
serialized with the value it actually computed, not a default:
iex> defmodule MyApp.Upstream.CallFailed do
...> use Errata.InfrastructureError
...> def http_status(%{reason: :timeout}), do: 504
...> def http_status(_error), do: 503
...> end
iex> Errata.to_map(MyApp.Upstream.CallFailed.new(reason: :timeout)).http_status
504In JSON the atoms arrive as strings while http_status stays an integer and
retryable a boolean, so a consumer can branch on them without parsing
anything:
{
"error_type": "MyApp.Http.RequestFailed",
"code": null,
"reason": "timeout",
"message": "the request to an upstream service failed",
"kind": "infrastructure",
"http_status": 503,
"severity": "error",
"retryable": true
}A wrapped cause and the members of an aggregate serialize through the same
to_map/1, so each carries its own classification rather than inheriting the
outer error's — a 422 validation failure wrapping a 503 upstream error
reports both.
Two things worth being clear about:
- Match on
code, noterror_type. The classification tells a consumer what to do; it does not identify the error.error_typeis a module name and moves when you move the module — see stable external error codes above. - A consumer needs nothing from Errata to use this. The four keys are plain JSON values, which is the point: the far side can be another language, or a service that has never heard of this library. If the receiving side is an Elixir application that has the error type compiled, it can go further — see rebuilding an error below.
Errata.log/2 and Errata.report/2 attach the same classification as metadata,
so a telemetry handler can route and tag on exactly what a boundary branches on
— see Reporting errors.
Two audiences, two projections
to_map/1 is the full record. It is aimed at an error reporter, which wants
everything it can get — including :env, the module, function, and source line
where the error was created.
That is the wrong map to hand a client. A response body should carry what the
caller can act on and nothing else, and in particular should not describe your
source layout. Pass :only or :except to say which audience you are serving:
iex> alias MyApp.Http.RequestFailed
iex> map = Errata.to_map(RequestFailed.new(reason: :timeout), only: [:code, :message, :retryable])
iex> Map.keys(map) |> Enum.sort()
[:code, :message, :retryable]iex> alias MyApp.Http.RequestFailed
iex> map = Errata.to_map(RequestFailed.new(reason: :timeout), except: [:env])
iex> Map.has_key?(map, :env)
falseThe projection reaches aggregate members and a wrapped Errata cause too, so
except: [:env] removes every :env in the structure rather than only the
outermost one. Keys are validated, so a misspelled one raises rather than
quietly selecting nothing.
The encoder protocols emit the full map
Jason.encode!/1andJSON.encode!/1on an error struct produceto_map/1— the full record,:envincluded. Sorender("error.json", error: error)handing the struct straight to an encoder puts your source paths in the response body. At a client-facing boundary, select the fields and encode the resulting map rather than the error.
The source path that to_map/1 does emit is relative to the project root of the
application that defined the error type, so it reads lib/my_app/orders.ex:29
rather than naming the directory layout of the machine that built the release.
A worked boundary, end to end
The pieces above appear separately throughout this guide. Here they are together, which is the only place all of the decisions have to be made at once. Errata stays free of any web-framework dependency, so the glue below is application code — but it is the same glue in every application, so here it is.
The view
defmodule MyAppWeb.ErrorJSON do
@client_fields [:code, :message, :retryable, :errors]
def render("error.json", %{error: error}) do
Errata.to_map(error, only: @client_fields)
end
endThat is the whole view. A single-error response:
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.to_map(OrderNotFound.new(reason: :not_found), only: [:code, :message, :retryable, :errors])
%{code: "ORDER_NOT_FOUND", message: "the requested order does not exist", retryable: false}and a validation response, from the same call with no branching:
iex> alias MyApp.Orders.{ValidationFailed, EmailInvalid, PostcodeRequired}
iex> invalid = ValidationFailed.new(errors: [EmailInvalid.new(), PostcodeRequired.new()])
iex> Errata.to_map(invalid, only: [:code, :message, :retryable, :errors])
%{
code: "VALIDATION_FAILED",
message: "the order could not be validated",
retryable: false,
errors: [
%{code: "EMAIL_INVALID", message: "email is not a valid address", retryable: false},
%{code: "POSTCODE_REQUIRED", message: "postcode is required", retryable: false}
]
}Two properties are doing the work. The projection recurses into members, so
each one is rendered by the same rule as the top-level error — including its own
code, which is what a client matches on. And :errors is simply absent for
a type that is not an aggregate, so the single clause covers both shapes without
asking which one it has.
The controller
defmodule MyAppWeb.FallbackController do
use Phoenix.Controller
def call(conn, {:error, value}) do
error = MyApp.Errors.to_error(value)
Errata.log(error)
conn
|> put_status(Errata.http_status(error))
|> put_view(MyAppWeb.ErrorJSON)
|> render("error.json", error: error)
end
endMyApp.Errors.to_error/1 is the funnel from
classifying the types you recognize — it
turns an Ecto.Changeset, a {:error, :timeout}, or anything else the boundary
meets into an Errata error, so everything below it can assume one. Errata.log/2
with no level logs at the error's own severity/1, so a :warning-severity
domain error does not page anyone; see Reporting errors for
the telemetry side.
The status comes from Errata.http_status/1, and an aggregate answers it for the
whole collection — the members' status when they agree, its own when they do not.
Why the fields are selected rather than handed over
The shorter implementation of this view is Errata.to_map(error). Here is what
that hands a client:
iex> alias MyApp.Orders.OrderNotFound
iex> OrderNotFound.new(reason: :not_found) |> Errata.to_map() |> Map.keys() |> Enum.sort()
[:cause, :code, :context, :env, :error_type, :http_status, :kind, :message, :reason, :retryable, :severity]:env is your module, function, and source line. :context is whatever the error
site put there. error_type is a module name, which moves when you move the
module — the reason stable external codes exist.
Naming the fields also means adding a field to an error cannot silently widen your public API. The list is the contract you are offering clients, so it is worth writing down somewhere, and the view is the natural place.
Rebuilding an error from its encoded form
Everything above works from the encoded map alone. When the receiving side is an
Elixir application that already has the error type compiled — a service you
deploy alongside this one, a job runner, a consumer of your own queue — it can
turn the map back into an error with Errata.from_map/3, and get pattern
matching and the guards back with it:
iex> alias MyApp.Orders.OrderNotFound
iex> encoded = OrderNotFound.new(reason: :not_found) |> Jason.encode!() |> Jason.decode!()
iex> {:ok, error} = Errata.from_map(OrderNotFound, encoded)
iex> match?(%OrderNotFound{}, error)
true
iex> Errata.http_status(error)
422The type is an argument rather than something read from the encoded
error_type. Resolving a module from a name off the wire would mean trusting
that name and keeping a registry of every error type — the central registry
the structural is_error/1 guard exists to avoid. Passing the type keeps the
decision at the call site, where you already know what you asked for.
Use from_map!/3 when a malformed payload means something is broken rather than
something a caller sent wrong; it returns the error directly and raises
otherwise.
What a decoded error is, and is not
It is a faithful classification, not a faithful reconstruction:
:kind,http_status/1,severity/1andretryable?/1are recomputed from the type here, and the encoded values are ignored. The receiver's own definitions win, so a decoded error behaves identically to a locally-created one even when the sender is running an older version of the type.:envis alwaysnil— it described a location in the sending process.:causeis kept as the decoded value rather than rebuilt into an error, since that would need its module too.- Context redacted on the way out stays redacted. The original values were never on the wire.
Two limits worth knowing before you reach for it. Aggregate types are
refused — each member carries its type only as a name, so rebuilding them
needs exactly the registry above; decode members individually and rebuild the
aggregate with new/1. And context keys come back as strings by default,
since context is arbitrary data and converting it is where an atom-exhaustion
risk would live; pass keys: :existing_atoms to convert the ones that already
exist.
A type that declares :reasons decodes its reason by matching against that
declared set, so nothing from the wire ever reaches String.to_existing_atom/1.
That makes :reasons worth declaring on any type you expect to cross a
boundary:
iex> defmodule MyApp.Orders.PaymentRejected do
...> use Errata.DomainError, reasons: [:declined, :expired]
...> end
iex> Errata.from_map(MyApp.Orders.PaymentRejected, %{"reason" => "timeout"})
{:error, {:unknown_reason, "timeout"}}:timeout is a perfectly ordinary atom in any running system; it is refused
here because it is not one of this type's reasons.
Rendering an error for users
Exception.message/1 (and the String.Chars implementation) return a
developer-oriented message that combines the :message and :reason (for
example, "the requested order does not exist: :not_found") — useful in logs
and raised-exception output. When rendering an error for an end user, use
Errata.display_message/1 instead, which returns just the human-readable
:message.
Dynamic messages
A static :default_message cannot name the thing that went wrong — it can say
"the item is out of stock" but not which item. Rather than building the string
by hand at every call site, override the generated display_message/1 to compute
it from the error's :reason or :context, once, where the type is defined:
defmodule MyApp.Orders.ItemOutOfStock do
use Errata.DomainError, default_message: "the item is out of stock"
def display_message(%{context: %{sku: sku, available: available}}),
do: "only #{available} of #{sku} left in stock"
def display_message(error), do: error.message
endErrata.display_message/1 and Errata.to_map/1 both dispatch through it, so the
computed message reaches the JSON encoding and anything else rendering the error
for a user. Keep a final clause returning error.message so the type still has a
sensible message when the context it wants is absent:
iex> alias MyApp.Orders.ItemOutOfStock
iex> error = ItemOutOfStock.new(reason: :insufficient_stock, context: %{sku: "ABC-1", available: 2})
iex> Errata.display_message(error)
"only 2 of ABC-1 left in stock"
iex> Errata.to_map(error).message
"only 2 of ABC-1 left in stock"
iex> Errata.display_message(ItemOutOfStock.new(reason: :insufficient_stock))
"the item is out of stock"This is a plain function rather than a template syntax, so it is just pattern matching: one clause per shape of context, with the compiler checking it and no separate interpolation language to learn.
One error, two surfaces
display_message/1 is the surface-independent phrasing: the words that hold
wherever the error can be shown. That is worth deciding deliberately, because one
error type usually surfaces in more than one place.
Take an authentication failure from a third-party API. On a background transfer report, the connection has gone stale and the useful message is "reconnect to continue". The same error comes back from the connect form, where credentials are being entered for the first time — there is nothing to reconnect to, and the user is looking at the three fields they just typed. The right message there is about the fields.
Neither phrasing is wrong; they answer different questions, and only the call site
knows which one is being asked. So put the phrasing that holds everywhere in the
type, and match on Errata.reason/1 where a surface needs different words:
def error_text(error) do
case Errata.reason(error) do
:unauthorized -> "Check your username and password and try again."
_ -> Errata.display_message(error)
end
endKept the other way round, every error type would have to know the set of places it can be rendered — a set that grows with the application, and that the error site has no way to see.
The developer message is unaffected either way: Exception.message/1 keeps
combining :message and :reason for logs and raised output, whatever
display_message/1 does. See
rendering an error for users above.
The developer message is deliberately unaffected by the override above:
iex> alias MyApp.Orders.ItemOutOfStock
iex> error = ItemOutOfStock.new(reason: :insufficient_stock, context: %{sku: "ABC-1", available: 2})
iex> Exception.message(error)
"the item is out of stock: :insufficient_stock"Logs and raised-exception output keep the stable, greppable message while the
specifics stay queryable in the metadata that Errata.log/2 attaches. If you do
want the computed detail in the developer message too, override message/1 as
well — that one applies to Exception.message/1, to_string/1, and raise.