View Source Errata usage rules

Structured, named error handling. An Errata error is an ordinary Exception struct that can be returned as a value or raised, carrying a message, a reason atom, a context map, a cause (the error it wrapped), and an env (module, function, file, line, stacktrace).

Taken together, an application's error types are a named catalogue of the ways it can fail.

Setup

# mix.exs
{:errata, "~> 1.9"}

JSON encoding needs no configuration: on Elixir 1.18+ every error type implements the built-in JSON.Encoder; if jason is present it implements Jason.Encoder too. Both emit the same shape.

In any module that creates or classifies errors:

use Errata     # not `require` — this requires AND imports the three guards

Rule 0: define error types in compiled code

This is the trap that costs the most time, because it fails far from its cause.

use Errata.DomainError and friends generate String.Chars and JSON protocol implementations, and protocols are consolidated when your project compiles. A type defined after that point gets none of them. Defining one in a .exs script, an iex session, or inside a test module body produces three "protocol has already been consolidated" warnings at compile time, and then, much later and somewhere else:

** (Protocol.UndefinedError) protocol String.Chars not implemented for %Bare{...}

Only the protocol paths break — Errata.to_map/1 and the accessors work regardless — which is why it can go unnoticed until something calls to_string/1.

Define error types in lib/. In tests, define fixture types at the top level of the test file, above the test module, or set consolidate_protocols: Mix.env() != :test in mix.exs.

Defining a type

Pick a kind. The kind decides how a boundary treats the error; the type decides how your domain logic behaves.

defmodule MyApp.Orders.PaymentDeclined do
  use Errata.DomainError,          # a business-rule violation, inside the problem domain
    default_message: "the payment was declined",
    reasons: [:insufficient_funds, :card_expired]
end

defmodule MyApp.Orders.GatewayTimeout do
  use Errata.InfrastructureError   # network, database — outside the problem domain
end

defmodule MyApp.UnexpectedError do
  use Errata.Error                 # kind :general — fits neither
end

Prefer DomainError and InfrastructureError over the base Errata.Error: the classification is what lets a boundary route errors without knowing every type.

Every option is optional:

OptionPurpose
:default_message / :default_reasonused when none is given
:reasonsdeclare the valid reasons — compile-time validated, and the basis of atom safety in from_map/3
:http_status, :code, :severity, :retryableclassifications consumed at a boundary
:redactkeep sensitive context out of logs and JSON
:aggregatea type that carries several errors at once

Declaring :reasons is worth doing by default. It catches typos at compile time, generates a reason/0 type, and is what makes decoding an error from the wire safe — a declared set turns decoding into a lookup, so nothing from outside is ever atomised.

Creating errors

Three ways, differing in setup and in whether they record where the error came from.

# Default. Captures __ENV__ and the stacktrace into :env. One `use Errata` covers every type.
{:error, Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: id})}

# Same thing, reads better when a module works mostly with one type. Needs `require OrderNotFound`.
{:error, OrderNotFound.create(reason: :not_found)}

# A plain function. No :env captured.
{:error, OrderNotFound.new(reason: :not_found)}

Reach for Errata.create/2 unless you have a reason not to. The origin of an error is often the most useful thing you have when debugging, and capturing it costs ~0.7 µs — negligible next to anything that can fail.

new/1 exists for the cases a macro cannot serve, and those are the only reasons to prefer it:

  • dynamic dispatch — apply(OrderNotFound, :new, [params]); a macro raises UndefinedFunctionError
  • capture — &OrderNotFound.new/1; capturing a macro freezes the capture site's env into every error it builds
  • tests and fixtures, where env: nil keeps structs easy to compare

create must be a macro, and cannot be reimplemented as a function that derives the call site from the stacktrace: tail-call optimisation drops the caller's frame, so e = Err.new(...); e would silently report the caller's caller.

Raising uses the same type:

raise MyApp.Orders.OrderNotFound, reason: :not_found, context: %{order_id: 42}

Wrapping: the cause chain

Wrap a lower-level failure rather than discarding it:

rescue
  e -> {:error, Errata.wrap(MyApp.Orders.GatewayTimeout, e, stacktrace: __STACKTRACE__)}

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. That shape decides which accessor you want:

Errata.root_error(error)                       # deepest ERRATA error — has code, context, classification
Errata.root_error(error) |> Errata.cause()     # the foreign original, or nil
Errata.format_chain(error)                     # the whole chain, stacktraces included, for a log

Do not hand-roll a recursive unwrap loop, and do not use Errata.root_cause/1 — it is deprecated precisely because it returns an Errata error or a foreign value depending on how the chain ends, so the caller has to work out which it got. Use root_error/1 to render, report or classify; cause/1 on it to diagnose what actually failed.

This is where a shared error library pays off: a RetriesExhausted from external_service wrapping your own error, neither knowing about the other, still unwraps to "connection refused" — which is the message a user can act on, where "could not be completed after 3 attempts" is not.

Handling errors

The three guards are defguards, so a module calling them fully qualified still needs require Errata. use Errata does that for you and imports them unqualified:

use Errata

case do_something() do
  {:error, e} when Errata.is_domain_error(e) -> render_to_user(e)
  {:error, e} when Errata.is_infrastructure_error(e) -> retry_later(e)
  {:error, e} when Errata.is_error(e) -> report(e)
  {:error, other} -> report_foreign(other)
end

Every accessor raises on a non-Errata value

reason/1, context/1, kind/1, code/1, severity/1, retryable?/1, http_status/1, cause/1, root_error/1, display_message/1 — all of them raise ArgumentError when handed something that is not an Errata error.

That matters because the boundary where you ask these questions is exactly the boundary where other error shapes arrive. An Oban worker receiving {:error, %Ecto.Changeset{}} alongside your own errors will raise inside error handling — the worst place for it, since it replaces a real error with an unrelated one.

Two correct shapes:

# Guard first
{:error, reason} when Errata.is_error(reason) ->
  if Errata.retryable?(reason), do: {:snooze, 60}, else: give_up(reason)

{:error, reason} ->
  give_up(reason)
# Or normalise first, and treat everything uniformly
error = Errata.to_error(reason)
if Errata.retryable?(error), do: {:snooze, 60}, else: give_up(error)

display_message/1 returns nil when there is no message to show

Specifically, when the type declares no :default_message and none was given — verified on 1.8.0. So call sites generally need a fallback: Errata.display_message(e) || Exception.message(e).

Note also that display_message/1 is written for one audience at a time. The same error may want different phrasing in a background report and on the form the user is staring at — special-casing at the call site is legitimate.

At a boundary

Errata.to_error/2 normalises any value into an Errata error, which is what makes a catch-all handler possible. The recommended shape is your own to_error/1 with ordinary clauses, so one function shows how a boundary classifies errors:

defmodule MyAppWeb.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)
end

{:error, reason} tuples are not unwrapped. Errata.to_error({:error, :timeout}) normalises the two-tuple itself, because a value that legitimately is a two-tuple is indistinguishable from one meaning "error". Match the tuple at the call site: {:error, reason} -> {:error, Errata.to_error(reason)}.

Then route on classification rather than on type:

conn |> put_status(Errata.http_status(error)) |> json(Errata.to_map(error))

to_map/1 and both JSON encoders carry kind, http_status, severity, retryable and code, so a consumer holding only the serialised error can still route on it. Errata.from_map/3 rebuilds one on the far side — the type is an argument, not read from the payload, and :reasons is what keeps it safe.

Reporting

Errata.log(error)              # structured Logger metadata; level defaults to severity(error)
Errata.report(error)           # emits [:errata, :error] telemetry for your own handler
Errata.report(error, log: true)

Vendor-neutral — wire the telemetry event to Sentry or wherever errors should go. Use :redact on types whose context can hold secrets; the library tells you to put arbitrary metadata in context and then ships it to Logger, telemetry and JSON.

Two things that will surprise you

Structural guards are invisible to the Elixir type checker. is_error/1 matches on struct shape, which does not refine a struct type, so e.reason after a bare rescue or guard warns on 1.18+. Use the accessors (Errata.reason(e)) or Map.fetch!(e, :reason).

Dialyzer's :extra_return flag is unusable in an Errata application. Generated accessors are specced to the behaviour's contract, not to one implementation — code/1 is String.t() | nil though a type declaring code: "..." only ever returns the string; retryable?/1 is boolean() though a domain error only ever returns false. The warning count grows with every error type defined. Leave the flag off.

Aggregates

For a type that carries several errors at once (validation, batch work), use Errata.DomainError, aggregate: true. Errata.errors/1 returns [] for an ordinary error rather than raising, so calling code can treat every error uniformly instead of branching on aggregate?/1 first:

for member <- Errata.errors(error), do: Logger.warning(Exception.message(member))