View Source Errata (Errata v1.9.0)
Errata is an Elixir library for structured, named error handling.
In Elixir it is common to signal failure either by returning an error tuple
({:error, reason}) or by raising an exception. Errata embraces both styles,
but replaces ad-hoc reasons and loosely structured exceptions with named,
structured error types that share a consistent shape and carry full contextual
detail about what went wrong and where.
Taken together, an application's Errata types form a kind of errata sheet for the system: a deliberate, named catalogue of the ways it can fail.
Each Errata error is an Exception struct with a well-defined set of fields:
message— a human-readable description of the errorreason— an atom that classifies the error, useful for pattern matchingcontext— a map of arbitrary metadata captured at the site of the errorcause— the original error wrapped by this one, when it was created from a lower-level failure (seeErrata.CauseandErrata.cause/1)env— the module, function, file, line, and stacktrace where the error was created (seeErrata.Env)
Because the full context is embedded in the struct, it travels with the error whether the error is raised or returned as a value, and can be logged, reported, or rendered to JSON at the boundaries of the system without losing the information needed to interpret it.
With Errata you can:
- Define custom error types in one line with
use Errata.DomainError,use Errata.InfrastructureError, oruse Errata.Error. - Use an error as a value or an exception — the same type can be returned
in an
{:error, error}tuple or raised withraise/2. - Capture rich context — an error reason, arbitrary metadata, and the exact point of origin (module, function, file, line, and stacktrace).
- Wrap lower-level errors — catch an exception or error value and wrap it
as the
:causeof a structured Errata error, without losing the original. - Classify errors as domain, infrastructure, or general, and branch on
that classification at system boundaries with the
Errataguards. - Serialize errors automatically — every error type implements the
String.Charsprotocol and, depending on what's available, the built-inJSON.Encoder(Elixir 1.18+) and/orJason.Encoderprotocols. - Report errors at a boundary — log an error with its fields as structured metadata, or emit a telemetry event for your own handler to forward to Sentry, a metrics backend, or wherever errors should go.
Quick start
# Define a domain error. Errata generates the exception struct, the
# `Errata.Error` behaviour, and the String.Chars and Jason.Encoder protocols.
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError,
default_message: "the requested order does not exist"
end
defmodule MyApp.Orders do
require Errata
# Return the error as a value, capturing the reason, some context, and the
# point of origin (via `Errata.create/2`).
def fetch_order(id) do
with :error <- lookup(id) do
{:error, Errata.create(MyApp.Orders.OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
end
# ...or raise the very same type as an exception.
def fetch_order!(id) do
case fetch_order(id) do
{:ok, order} -> order
{:error, error} -> raise error
end
end
endAn Errata error carries its full context with it, and can be rendered to a string or to JSON for logging and error reporting:
error = MyApp.Orders.OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
to_string(error)
#=> "the requested order does not exist: :not_found"
Jason.encode!(error)
#=> ~s({"error_type":"MyApp.Orders.OrderNotFound","reason":"not_found", ...})The three kinds of errors
Every Errata error has a kind, fixed when the type is defined:
- Domain errors — business-rule violations and other failures within the
problem domain. Define them with
Errata.DomainError. - Infrastructure errors — network timeouts, database failures, and other
failures outside the problem domain. Define them with
Errata.InfrastructureError. - General errors — anything that fits neither, or where the distinction does
not matter. Define them with the base
Errata.Error.
An error's kind decides how a boundary treats it; its type decides how your domain logic behaves. For how to choose between them, what each kind defaults to, and how to opt out of the taxonomy entirely, see the design notes.
Defining custom error types
Most errors in an application are either domain errors or infrastructure errors, so Errata provides a dedicated module for each. Prefer these two when defining custom error types: they make the classification explicit and let domain and infrastructure errors be identified throughout the system.
defmodule MyApp.Orders.PaymentDeclined do
# A business-rule violation or other error within the problem domain.
use Errata.DomainError
end
defmodule MyApp.Orders.PaymentGatewayTimeout do
# A network timeout, database failure, or other infrastructure-level error.
use Errata.InfrastructureError
endFor the occasional error that fits neither category — such as an error
originating in library code — use the base Errata.Error module, which creates
an error of kind :general:
defmodule MyApp.UnexpectedError do
# An error that is neither a domain nor an infrastructure error.
use Errata.Error
endEvery option is optional. The two you are likely to reach for first:
:default_message— the:messageto use when none is given:default_reason— the:reasonto use when none is given
The rest are classifications consumed at a boundary — :http_status, :code,
:severity, :retryable — plus :reasons (declare the valid reasons for the
type), :redact (keep sensitive context out of logs and JSON), and :aggregate
(a type that holds several errors at once). See
Errors at a boundary,
Reporting errors, and
Wrapping and composing errors, or
Errata.Error for the full reference.
Whichever module you use, the resulting error type is an exception struct that
conforms to the Errata.error/0 type, implements the Errata.Error
behaviour, and provides String.Chars and Jason.Encoder implementations so
that it can be rendered as a string or encoded as JSON automatically.
Define error types in compiled code
Because those protocol implementations are consolidated when your project compiles, an error type defined after consolidation gets none of them. Defining one in a
.exsscript, aniexsession, or inside a test module body produces three "protocol has already been consolidated" warnings at compile time and then, much later and somewhere else entirely:** (Protocol.UndefinedError) protocol String.Chars not implemented for %Bare{...}Protocol implementations are consolidated when your project compiles, so a type defined after that point gets none of the three. Only the protocol paths are affected —
Errata.to_map/1and the accessors work on such a type regardless — which is why this can go unnoticed until something callsto_string/1.Define error types in
lib/. In tests, either define fixture types at the top level of the test file, above the test module, or setconsolidate_protocols: Mix.env() != :testinmix.exs— the first is local and needs no project change, the second is one line and removes the trap for the whole suite. This project does both. See Testing with Errata for this and the other things worth knowing before writing the first test.
Creating errors as return values
Returning an error as a value — preferably wrapped in an {:error, error}
tuple — lets you create the error with full context at the site where it occurs,
while leaving the handling of the error to callers further up the stack. The
error can then be logged or reported at a system boundary without losing any of
its context.
There are three ways to create an error. They differ in how much setup they need and in whether they record where the error came from.
Errata.create/2 is the one to reach for by default. It captures the current
__ENV__ and stacktrace into the :env field, and because it takes the error
type as an argument, a single use Errata covers every error type the module
creates — there is no per-type require:
iex> require Errata
iex> alias MyApp.Orders.OrderNotFound
iex> error = Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: 42})
iex> error.reason
:not_found
iex> match?(%Errata.Env{}, error.env)
trueIn a real module, write use Errata rather than require Errata — it does the
same require and brings the guards into scope at the same
time:
defmodule MyApp.Orders do
use Errata
alias MyApp.Orders.OrderNotFound
alias MyApp.Orders.PaymentDeclined
def find(id) do
{:error, Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
def pay(_order) do
{:error, Errata.create(PaymentDeclined, reason: :insufficient_funds)}
end
endcreate/1 on the error module does exactly the same thing, and reads a
little more directly when a module works mostly with one error type. It is a
macro on the error module, so that module must be required:
iex> require MyApp.Orders.OrderNotFound, as: OrderNotFound
iex> error = OrderNotFound.create(reason: :not_found, context: %{order_id: 42})
iex> error.reason == :not_found
true
iex> error.context == %{order_id: 42}
true
iex> match?(%Errata.Env{stacktrace: stacktrace} when is_list(stacktrace), error.env)
truenew/1 is a plain function that builds the error without environment info:
iex> alias MyApp.Orders.OrderNotFound
iex> OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
%OrderNotFound{reason: :not_found, context: %{order_id: 42}, env: nil}Which should I use?
Use
Errata.create/2— orcreate/1if you haverequired the error module — unless you have a reason not to. The module, function, file, line, and stacktrace of an error's origin are often the most useful things you have when debugging, and capturing them costs on the order of a microsecond, which is negligible next to almost any operation that can fail. Both are macros, which is what lets them see the call site at all.
new/1is for the cases a macro cannot serve. It can be called dynamically —apply(OrderNotFound, :new, [params])— where a macro raisesUndefinedFunctionError, and it can be captured as&OrderNotFound.new/1and passed around, where capturing a macro would freeze the environment of the capture site into every error it builds. It is also handy in tests and fixtures, whereenv: nilkeeps error structs easy to compare.
However the error is created, wrap it in a tuple when returning it from a function:
{:error, Errata.create(OrderNotFound, reason: :not_found)}
{:error, OrderNotFound.create(reason: :not_found)}
{:error, OrderNotFound.new(reason: :not_found)}Raising errors as exceptions
Because Errata errors are ordinary Elixir exceptions, the same type can also be
raised with raise/2, passing params as the second argument:
raise MyApp.Orders.OrderNotFound, reason: :not_found, context: %{order_id: 42}Guides
The sections above are the whole of what most applications need. The guides cover the rest, and follow the life of an error — handled, composed as it travels, converted where it leaves, reported:
- Handling errors — the guards,
use Errata, and matching on errors as values versus rescuing them as exceptions. - Wrapping and composing errors — wrapping a
lower-level failure as a
:cause, enriching context as an error propagates, and aggregate errors that carry several errors at once. - Errors at a boundary — HTTP status codes, stable external codes, severity and retryability, normalizing errors your application did not define (and when that differs from wrapping), carrying an error's classification across the wire and rebuilding it on the far side, and rendering an error for a user.
- Reporting errors —
Errata.log/2,Errata.report/2, the telemetry contract, and redacting sensitive context. - Testing with Errata — where fixture types must be defined, asserting on errors readably, proving redaction works, and the telemetry and log seams.
- Design notes — choosing a kind, choosing between an error type and a reason, and why Errata works the way it does.
Summary
Types
Type to represent Errata domain errors.
Type to represent any kind of Errata error.
Type to represent the various kinds of Errata errors.
Type to represent Errata infrastructure errors.
Functions
Brings Errata's guards into scope and requires the module.
Returns true if error is an aggregate — a type declared with
aggregate: true, which can hold member errors.
Returns the immediate cause wrapped by error, or nil if it has none.
Returns the stable external code for error, or nil if it has none.
Returns error's :context, or %{} if it has none.
Creates an error of the given error_module, capturing the current __ENV__
and stacktrace into the :env field.
Returns the human-readable display message for an error: the value of its
:message field, or nil if none was set.
Returns the member errors of an aggregate, or [] for an ordinary error.
Renders error and its full cause chain as a multi-line string for logging.
Rebuilds an error of the given type from its encoded form.
Same as from_map/3, but returns the error directly and raises on failure.
Returns the HTTP status code associated with error.
Returns true if term is an Errata domain error type; otherwise returns false.
Returns true if term is any Errata error type; otherwise returns false.
Returns true if term is an Errata infrastructure error type; otherwise returns false.
Returns error's kind: :domain, :infrastructure, or :general.
Logs error at the given level with its structured fields attached as Logger
metadata.
Returns a copy of error with the key/value pairs from context merged into
its :context map.
Returns a copy of error with value stored under key in its :context map.
Returns error's :reason, or nil if it has none.
Emits a :telemetry event for error, and optionally logs it.
Returns true if error is considered retryable.
Walks the cause chain of error and returns the deepest thing in it.
Returns the deepest Errata error in error's cause chain.
Returns the severity of error, as a Logger.level/0.
Converts any value into an Errata error.
Converts any Errata error to a plain, JSON-encodable map.
Wraps cause in a new error of the given error_module, capturing the current
__ENV__ and stacktrace into the :env field.
Types
@type domain_error() :: %{ :__struct__ => module(), :__exception__ => true, :__errata_error__ => true, :kind => :domain, :message => String.t() | nil, :reason => atom() | nil, :context => map() | nil, :cause => Errata.Cause.t() | nil, :env => Errata.Env.t() | nil, optional(:errors) => [error()] }
Type to represent Errata domain errors.
@type error() :: %{ :__struct__ => module(), :__exception__ => true, :__errata_error__ => true, :kind => error_kind(), :message => String.t() | nil, :reason => atom() | nil, :context => map() | nil, :cause => Errata.Cause.t() | nil, :env => Errata.Env.t() | nil, optional(:errors) => [error()] }
Type to represent any kind of Errata error.
Errata errors are Exception structs that have additional fields to contain extra contextual
information, such as an error reason or details about the context in which the error occurred.
@type error_kind() :: :domain | :infrastructure | :general | nil
Type to represent the various kinds of Errata errors.
@type infrastructure_error() :: %{ :__struct__ => module(), :__exception__ => true, :__errata_error__ => true, :kind => :infrastructure, :message => String.t() | nil, :reason => atom() | nil, :context => map() | nil, :cause => Errata.Cause.t() | nil, :env => Errata.Env.t() | nil, optional(:errors) => [error()] }
Type to represent Errata infrastructure errors.
Functions
Brings Errata's guards into scope and requires the module.
use Errata is the simplest way to set up a module that handles or creates
Errata errors. It is exactly equivalent to importing just the three guards:
import Errata, only: [is_error: 1, is_domain_error: 1, is_infrastructure_error: 1]This makes is_error/1, is_domain_error/1, and is_infrastructure_error/1
available unqualified — including in when clauses and function heads — and,
because import implies require, also makes the create/2 and wrap/3
macros callable in their qualified form (Errata.create/2, Errata.wrap/3).
defmodule MyApp.Orders.Boundary do
use Errata
def handle({:error, e}) when is_error(e), do: handle_errata_error(e)
def handle({:error, e}), do: handle_other_error(e)
endOnly the guards are imported. The rest of the Errata API stays qualified
(Errata.to_map/1, Errata.put_context/3, Errata.report/2, and so on),
which keeps generically named functions out of your module's namespace and
reads clearly at a boundary.
Not the same as
use Errata.Error
use Erratais for modules that work with errors. To define a new error type,use Errata.Error(orErrata.DomainError/Errata.InfrastructureError) instead.
Returns true if error is an aggregate — a type declared with
aggregate: true, which can hold member errors.
Note this is about the type, not the contents: an aggregate with no members is still an aggregate.
Raises an ArgumentError if error is not an Errata error.
Returns the immediate cause wrapped by error, or nil if it has none.
The cause is the original error, exception, or value that was wrapped when the
error was created (typically via the generated Errata.Error.wrap/2 macro,
or by passing a :cause to Errata.Error.new/1 or Errata.Error.create/1).
This returns the bare wrapped value; the captured stacktrace (if any) is held
in the error's :cause field as an Errata.Cause struct.
iex> alias MyApp.Orders.OrderNotFound
iex> require OrderNotFound
iex> original = %RuntimeError{message: "boom"}
iex> error = OrderNotFound.wrap(original, reason: :lookup_failed)
iex> Errata.cause(error)
%RuntimeError{message: "boom"}
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.cause(OrderNotFound.new(reason: :not_found))
nilRaises an ArgumentError if error is not an Errata error.
Returns the stable external code for error, or nil if it has none.
An error's type identity is its Elixir module, which is an implementation
detail: renaming or moving the module changes the only identifier that
to_map/1 exposes (error_type). That makes it a poor contract for external
consumers — API clients, i18n catalogs, support tooling — who need an
identifier that survives refactoring.
A code is that identifier. It is opt-in, and independent of the module name:
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError, code: "ORDER_NOT_FOUND"
endThe code appears in to_map/1 (and therefore in the JSON encoding) under the
code key, and in the metadata emitted by log/2 and report/2. Types that
do not declare one return nil, so a boundary that requires a code should
supply its own fallback:
Errata.code(error) || "UNKNOWN"Raises an ArgumentError if error is not an Errata error.
Returns error's :context, or %{} if it has none.
Returns an empty map rather than nil for an error created without context, so
calling code can treat the result as a map unconditionally.
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.context(OrderNotFound.new(context: %{order_id: 42}))
%{order_id: 42}
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.context(OrderNotFound.new())
%{}This is the error's unredacted context — the values as captured. Redaction
applies to what Errata serializes and emits (see Errata.Redaction); an error
in your own hands keeps the real values for debugging, and this accessor
reflects that.
Raises an ArgumentError if error is not an Errata error.
Creates an error of the given error_module, capturing the current __ENV__
and stacktrace into the :env field.
This is a convenience equivalent to the per-module Errata.Error.create/1
macro, but it lives on the Errata module. Because you typically already
require Errata (to use the guards above), you can alias your error modules
and call Errata.create/2 for any of them without a separate require for
each error type:
defmodule MyApp.Orders do
require Errata
alias MyApp.Orders.{OrderNotFound, PaymentDeclined}
def fetch_order(id) do
{:error, Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
endCompare to the per-module macro, which requires a require for every error
type used in the module:
require MyApp.Orders.OrderNotFound, as: OrderNotFound
require MyApp.Orders.PaymentDeclined, as: PaymentDeclined
Returns the human-readable display message for an error: the value of its
:message field, or nil if none was set.
This is distinct from Exception.message/1 (and the String.Chars
implementation), which return a developer-oriented message that also
includes the :reason — useful in logs and raised-exception output, but not
intended for end users. Use display_message/1 when rendering an error for a
user (for example, the body of a 4xx HTTP response), supplying your own
fallback for the nil case.
This delegates to the error module's generated display_message/1 function,
which returns the :message field unless the type overrides it. Override it to
compute a user-facing message from the error's :reason or :context; the
override applies here and in to_map/1 (and therefore the JSON encoding).
iex> alias MyApp.Orders.PaymentDeclined
iex> error = PaymentDeclined.new(reason: :insufficient_funds)
iex> Errata.display_message(error)
"the payment was declined"
iex> Exception.message(error)
"the payment was declined: :insufficient_funds"Raises an ArgumentError if error is not an Errata error.
Returns the member errors of an aggregate, or [] for an ordinary error.
Returning [] rather than raising for a non-aggregate means calling code can
treat every error uniformly — an ordinary error is simply an error with no
members — instead of branching on aggregate?/1 first:
for member <- Errata.errors(error) do
Logger.warning(Exception.message(member))
endSee Errata.Aggregate for how aggregates merge severity, retryability, and
HTTP status across their members.
Raises an ArgumentError if error is not an Errata error.
Renders error and its full cause chain as a multi-line string for logging.
The head is the error's own developer-oriented message (as returned by
Exception.message/1), followed by a Caused by: line for each wrapped cause.
Wrapped Errata errors recurse into their own chain; other wrapped values are
rendered via Exception.format/3, including the captured stacktrace when one
is present.
Unlike Exception.message/1, which is kept clean and reports only the error's
own message, this includes the entire chain — use it where you want the
underlying context surfaced, such as a log entry.
Raises an ArgumentError if error is not an Errata error.
Rebuilds an error of the given type from its encoded form.
This is the counterpart to to_map/1, for the receiving end of a boundary: a
service that consumes an error another service serialized, a job runner
reading a payload, a consumer taking a message off a queue. It accepts the map
produced by to_map/1 directly, or the result of decoding that map's JSON
(string keys are handled as well as atom keys).
iex> alias MyApp.Orders.OrderNotFound
iex> encoded = Errata.to_map(OrderNotFound.new(reason: :not_found))
iex> {:ok, error} = Errata.from_map(OrderNotFound, encoded)
iex> Errata.reason(error)
:not_found
iex> Errata.is_domain_error(error)
trueThe error type is an argument rather than something read from the encoded
error_type key. That key holds a module name, which is an implementation
detail Errata deliberately does not treat as an identifier — resolving it
would mean both trusting a name from the wire and keeping a registry of every
error type, which is exactly what the structural is_error/1 guard avoids.
What comes back, and what does not
A decoded error is a faithful classification, not a faithful reconstruction:
:reason,:messageand:contextare restored.:kind,http_status/1,severity/1andretryable?/1are recomputed from the type in this application, and the encoded values are ignored. The receiver's own definitions win, so an error decodes consistently with every locally-created error of the same type even if the sender is running an older version.:envis alwaysnil. It describes a location in the sending process, which would be actively misleading attached to an error here.:causeis kept as the plain decoded value rather than being rebuilt into an error, since doing so would need its module too.Errata.cause/1returns it;format_chain/1still shows it.- Context that was redacted on the way out stays redacted — the original values are not on the wire, and nothing here pretends otherwise.
Options
:keys— what the keys of the decoded:contextmap should be. Defaults to:strings, which is the shape context arrives in from JSON. Pass:existing_atomsto convert keys that already exist as atoms, which makes a context built locally round-trip to the same shape:iex> alias MyApp.Orders.OrderNotFound iex> encoded = Errata.to_map(OrderNotFound.new(context: %{order_id: 42})) iex> {:ok, error} = Errata.from_map(OrderNotFound, encoded, keys: :existing_atoms) iex> Errata.context(error) %{order_id: 42}Conversion is best-effort and recursive: a key with no existing atom is left as a string rather than being created, so decoding untrusted input cannot exhaust the atom table. The default is
:stringsbecause:contextholds arbitrary data, and that is where the risk would otherwise live.Both modes rewrite the keys, so the decoded shape depends only on this option — not on whether you passed JSON-decoded data or a map straight from
to_map/1.
Errors
Returns {:error, reason} rather than raising, since malformed input is an
expected condition where this is called. Passing something that is not an
Errata error type is a programming error and still raises ArgumentError.
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.from_map(OrderNotFound, %{"reason" => "no_such_reason_exists"})
{:error, {:unknown_reason, "no_such_reason_exists"}}A type that declares :reasons is decoded by matching against that declared
set, so no atom is created from external input at all. See from_map!/3 for
the raising variant.
Same as from_map/3, but returns the error directly and raises on failure.
iex> alias MyApp.Orders.OrderNotFound
iex> encoded = Errata.to_map(OrderNotFound.new(reason: :not_found))
iex> Errata.from_map!(OrderNotFound, encoded) |> Errata.reason()
:not_foundReach for this when the encoded form comes from somewhere you control — your
own job queue, a service you deploy alongside this one — and a malformed
payload means something is broken rather than something a caller sent wrong.
Use from_map/3 when the input is foreign and a bad payload is one of the
outcomes you expect to handle.
Raises ArgumentError on anything from_map/3 would return {:error, _} for.
@spec http_status(error()) :: non_neg_integer()
Returns the HTTP status code associated with error.
This delegates to the error module's generated http_status/1 function, which
defaults off the error's kind — :domain errors map to 422, :infrastructure
errors to 503, and :general errors to 500. A specific status can be set
per type with the :http_status option to use Errata.Error (and friends), or
by overriding http_status/1 to compute a status from the error's :reason or
:context.
This lets a boundary — such as a Phoenix fallback controller — map any Errata error to a response status without knowing its specific type:
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)
endRaises an ArgumentError if error is not an Errata error.
Returns true if term is an Errata domain error type; otherwise returns false.
Allowed in guard tests.
Returns true if term is any Errata error type; otherwise returns false.
Allowed in guard tests.
Returns true if term is an Errata infrastructure error type; otherwise returns false.
Allowed in guard tests.
@spec kind(error()) :: error_kind()
Returns error's kind: :domain, :infrastructure, or :general.
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.kind(OrderNotFound.new())
:domainFor branching on the kind, the is_domain_error/1 and
is_infrastructure_error/1 guards are usually the better tool, since they work
in a guard clause. This is for the cases that want the value itself — logging
it, or tagging a metric.
Raises an ArgumentError if error is not an Errata error.
@spec log(error(), Logger.level() | nil) :: :ok
Logs error at the given level with its structured fields attached as Logger
metadata.
When no level is given, the error's own severity/1 is used (which is
:error unless the error type sets a :severity).
The log message is the developer-oriented Exception.message/1 (combining
:message and :reason). The error's :reason, :kind, :context, and
origin :env are attached as Logger metadata rather than being flattened
into the message string, so they remain queryable structured fields in
backends that support them. The following metadata keys are set:
:error_type— the error's module:kind— the error's kind (:domain/:infrastructure/:general):reason— the error's reason:code— the error's stable external code, ornil(seecode/1):severity— the error's severity (seeseverity/1):retryable— whether the error is retryable (seeretryable?/1):http_status— the error's HTTP status (seehttp_status/1):context— the error's context map:env— a map of the originmodule,function,file, andline
Returns :ok. Raises an ArgumentError if error is not an Errata error.
Returns a copy of error with the key/value pairs from context merged into
its :context map.
Like put_context/3, but merges an entire map at once. On key collisions, the
values in the given context win (last-write-wins). If the error has no
context yet (nil), it is initialized from context.
iex> alias MyApp.Orders.OrderNotFound
iex> error = OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
iex> Errata.merge_context(error, %{user_id: 7, order_id: 99}).context
%{order_id: 99, user_id: 7}Raises an ArgumentError if error is not an Errata error, or if context is
not a map.
Returns a copy of error with value stored under key in its :context map.
Context is normally set once, at the site where an error is created. But a
structured error often travels up through several layers before reaching a
boundary, and intermediate layers frequently know context that the creation
site did not (the user_id known here, the request_id known there). Use
put_context/3 (or merge_context/2) to enrich an error's context as it
propagates, without rebuilding the struct by hand.
If the error has no context yet (nil), it is initialized to a map. An
existing value under key is overwritten.
iex> alias MyApp.Orders.OrderNotFound
iex> error = OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
iex> Errata.put_context(error, :user_id, 7).context
%{order_id: 42, user_id: 7}A typical use is enriching an error as it propagates through a with chain:
with {:error, err} <- fetch_order(id) do
{:error, Errata.put_context(err, :user_id, current_user_id)}
endRaises an ArgumentError if error is not an Errata error.
Returns error's :reason, or nil if it has none.
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.reason(OrderNotFound.new(reason: :not_found))
:not_found
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.reason(OrderNotFound.new())
nilEquivalent to reading error.reason, and preferable at a boundary that handles
errors generically: a variable bound by a bare rescue e -> has no type the
compiler can narrow, so e.reason there draws an "unknown key" warning — for
any exception, not just an Errata one. Going through the accessor is a plain
function call and warns for nothing.
Raises an ArgumentError if error is not an Errata error.
Emits a :telemetry event for error, and optionally logs it.
This is the seam for error reporting: rather than integrating with any particular external service, Errata emits a telemetry event that your application handles — attaching a handler that forwards to Sentry, a metrics backend, or wherever errors should go. The vendor integration stays in your application; Errata stays out of it.
The event is [:errata, :error], with:
- measurements
%{system_time: integer(), count: 1}—:countis always1, soTelemetry.Metrics.counter/2works out of the box - metadata containing the full
:errorstruct plus:kind,:reason,:error_type,:code,:severity,:retryable,:http_status, and:contextas top-level keys (simple values suitable for use as metric tags)
Options:
:metadata— a map or keyword list of extra metadata merged into the event. The standard keys above are protected: on a key collision, the standard value wins.:measurements— extra measurements merged into the event, with the standard measurements likewise protected.:log— also log the error vialog/2.false(the default) emits telemetry only;truelogs at the error's ownseverity/1; an atom level (e.g.:warning) logs at that level.
Returns :ok. Raises an ArgumentError if error is not an Errata error.
:telemetry.attach("myapp-errata", [:errata, :error], &MyApp.ErrorReporter.handle/4, nil)
Errata.report(error, metadata: %{request_id: request_id}, log: :warning)
Returns true if error is considered retryable.
This delegates to the error module's generated retryable?/1 function, whose
default is derived from the error's kind: :infrastructure errors are
retryable (timeouts and connection blips are usually transient), while
:domain and :general errors are not. Set it per type with the :retryable
option to use Errata.Error (and friends), or override retryable?/1 to
decide from the error's :reason or :context.
Errata deliberately provides no retry mechanism of its own — this is a
classification that your retry logic, or a library such as
ExternalService (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
endRaises an ArgumentError if error is not an Errata error.
Walks the cause chain of error and returns the deepest thing in it.
Deprecated
A cause chain is a chain of Errata errors, the deepest of which may carry a foreign original — the exception or value your code actually caught. This function is the one accessor that does not fit that model: it returns an Errata error or a foreign value depending on how the chain ends, so a caller has to work out which it got.
root_error/1gives you the deepest error, which always has acode, acontext, a classification and adisplay_message/1.cause/1on that error gives you the foreign original, ornilwhen there is none.format_chain/1renders the whole chain, stacktraces included, for a log.
root_cause(error)is equivalent tocause(root_error(error)) || root_error(error).
Raises an ArgumentError if error is not an Errata error.
Returns the deepest Errata error in error's cause chain.
A cause chain is Errata errors all the way down, optionally ending in one
foreign value — a bare atom, an {:error, reason} tuple, a standard exception.
root_cause/1 returns that bottom value whatever it is; this returns the
deepest thing in the chain that is still an Errata error, and so still carries
a code, a context, a classification and a display_message/1.
The two differ exactly when the chain bottoms out in a foreign value:
iex> alias MyApp.Http.RetriesExhausted
iex> require Errata
iex> error = Errata.wrap(RetriesExhausted, :econnrefused)
iex> Errata.root_error(error) == error
true
iex> Errata.root_error(error) |> Errata.cause()
:econnrefusedWhen the chain ends in an Errata error, they are the same value.
Reach for root_cause/1 to diagnose what failed — :econnrefused is the
answer a developer wants in a log. Reach for this to render, report or classify,
where a bare atom has nothing on it to use:
iex> alias MyApp.Http.RetriesExhausted
iex> require Errata
iex> Errata.wrap(RetriesExhausted, :econnrefused) |> Errata.root_error() |> Errata.code()
"RETRIES_EXHAUSTED"Raises an ArgumentError if error is not an Errata error.
@spec severity(error()) :: Logger.level()
Returns the severity of error, as a Logger.level/0.
This delegates to the error module's generated severity/1 function, which is
:error for every error type unless it says otherwise. Set a severity per type
with the :severity option to use Errata.Error (and friends), or override
severity/1 to compute one from the error's :reason or :context.
Severity is the level at which log/2 logs an error when no level is given
explicitly, and is included in the metadata of both log/2 and report/2, so
a telemetry handler can route or alert on it:
defmodule MyApp.Orders.RateLimited do
use Errata.DomainError, severity: :warning
endUnless a type opts in, the severity is :error:
iex> alias MyApp.Orders.OrderNotFound
iex> Errata.severity(OrderNotFound.new())
:errorRaises an ArgumentError if error is not an Errata error.
Converts any value into an Errata error.
Errata errors are returned unchanged, which makes this safe to apply to a value that may already have been normalized:
iex> alias MyApp.Orders.OrderNotFound
iex> error = OrderNotFound.new(reason: :not_found)
iex> Errata.to_error(error) == error
trueAnything else is wrapped in an Errata.UnknownError, keeping the original as
the cause. An atom also becomes the :reason:
iex> error = Errata.to_error(:timeout)
iex> error.__struct__
Errata.UnknownError
iex> Errata.reason(error)
:timeout
iex> Errata.cause(error)
:timeout
When to use this rather than wrap/3
Both turn an arbitrary value into an Errata error. The difference is whether you know what the failure means.
wrap/3 is an act of interpretation, used 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, and it always adds a layer because each layer's
interpretation is worth keeping. to_error/2 is used 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 comes back untouched.
That last part is the reason to keep them apart. Wrapping at a boundary replaces an already-correct classification with the wrapper's:
iex> require Errata
iex> error = MyApp.Orders.OrderNotFound.new(reason: :not_found)
iex> Errata.to_error(error) |> Errata.http_status()
422
iex> Errata.wrap(Errata.UnknownError, error) |> Errata.http_status()
500It is also a plain function rather than a macro, so it can be captured and
passed around (&Errata.to_error/1). The tradeoff is that it does not populate
the :env field: normalization usually happens in a generic boundary function,
where the call site is the boundary itself rather than anywhere informative
about the failure.
Classifying the types you know
A 500 is the right answer for a genuinely unknown value and the wrong answer
for an Ecto.Changeset, which is a 422, or a connection timeout, which is a
retryable 503. This function 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, cause: changeset)
def to_error(other), do: Errata.to_error(other)
endKeeping the recognized types in ordinary function clauses means a boundary reads one function to see how errors are classified, and that the classification can differ between boundaries where it needs to. See Errors at a boundary for the full pattern.
Options
:fallback- the error type to wrap unrecognized values in; defaults toErrata.UnknownError. Useful when an application has a catch-all type of its own.:kindand:stacktrace- describe the wrapped cause, as inwrap/3.
Any remaining options are passed as error params (:reason, :message,
:context), which is how a caller supplies a reason that the value itself
does not carry:
iex> error = Errata.to_error("connection reset", reason: :disconnected)
iex> Errata.reason(error)
:disconnected
Handling {:error, reason} tuples
Tuples are not unwrapped: to_error({:error, :timeout}) normalizes the
two-tuple itself, since a value that legitimately is a two-tuple is
indistinguishable from one that means "error". Match the tuple at the call
site instead:
case do_something() do
{:ok, result} -> result
{:error, reason} -> {:error, Errata.to_error(reason)}
endRaises ArgumentError if :fallback is not an Errata error type.
Converts any Errata error to a plain, JSON-encodable map.
This is the generic counterpart to the per-type Errata.Error.to_map/1
callback: it works on any value for which is_error/1 returns true,
without needing to know the error's specific module. This is convenient at
system boundaries (such as a Phoenix fallback controller) where errors of
many different types are handled uniformly.
iex> alias MyApp.Orders.OrderNotFound
iex> error = OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
iex> map = Errata.to_map(error)
iex> map.error_type
"MyApp.Orders.OrderNotFound"
iex> map.reason
:not_found
iex> map.context
%{order_id: 42}The map contains :error_type, :code, :reason, :message (the
display_message/1 rendering), :cause, :env, :context, and — for an
aggregate type — :errors.
It also carries the error's classification, so that code holding only the serialized form can decide what to do with it:
iex> alias MyApp.Orders.OrderNotFound
iex> map = Errata.to_map(OrderNotFound.new(reason: :not_found))
iex> {map.kind, map.http_status, map.severity, map.retryable}
{:domain, 422, :error, false}These four are computed through the same overridable functions as kind/1,
http_status/1, severity/1 and retryable?/1, so an override is reflected
here too. See
Errors at a boundary.
Most consumers of this map need nothing else — the classification is enough to
route, log and retry. An Elixir application that has the error type compiled
can turn the map back into an error with from_map/3.
Raises an ArgumentError if error is not an Errata error.
Projecting the map
to_map/1 is the full record, aimed at an error reporter that wants everything.
A response body crossing a boundary to a client wants much less — in particular
it should not carry :env, which names a source file and line. Pass :only or
:except (not both) to select:
Errata.to_map(error, except: [:env])
Errata.to_map(error, only: [:code, :message, :retryable])The projection reaches aggregate members and a wrapped Errata cause as well, so
except: [:env] removes every :env in the structure rather than only the
outermost one. A cause that is a plain exception rather than an Errata error is
left alone.
Keys are validated: a misspelled one raises rather than silently selecting nothing. See Errors at a boundary for which projection belongs on the wire and which belongs in your reporter.
Wraps cause in a new error of the given error_module, capturing the current
__ENV__ and stacktrace into the :env field.
This is a convenience equivalent to the per-module Errata.Error.wrap/2
macro, but it lives on the Errata module. As with create/2, you typically
already require Errata (for the guards above), so you can alias your error
modules and call Errata.wrap/3 for any of them without a separate require
for each error type:
defmodule MyApp.Orders do
require Errata
alias MyApp.Orders.OrderNotFound
def fetch_order(id) do
try do
external_lookup!(id)
rescue
e ->
{:error,
Errata.wrap(OrderNotFound, e, stacktrace: __STACKTRACE__, reason: :lookup_failed)}
end
end
endThe original error, exception, or value is stored as the new error's :cause;
retrieve it with Errata.cause/1 (or follow the chain with
Errata.root_cause/1). The opts are the same as for the per-module
Errata.Error.wrap/2 macro: the standard error params (:reason,
:message, :context) plus :stacktrace and :kind, which describe the
wrapped cause.
Wrapping is for when you know what a failure means — that is why it takes the
error type as an argument, and why it always adds a layer even around an error
that is already an Errata error. Where an error is on its way out of the
system and anything at all can arrive, use to_error/2 instead: it has no type
to name and leaves an already-classified error alone, where wrapping would
replace that error's status and user-facing message with the wrapper's.