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
end

Errata.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)
end

This 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)
:timeout

A 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/3to_error/2
do you know what it means?yes — you name the typeno — it is whatever arrived
wherewhere the failure is caughtwhere the error leaves
given an Errata erroradds a layerreturns it unchanged
formmacro; records the call sitefunction; 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)}
end

Classifying 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)
end

Each 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()
500

Plain 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)
end

Normalize 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"
end

The 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"
end

Classifying 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
end

Severity 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
end

Errata 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
end

Carrying 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
504

In 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, not error_type. The classification tells a consumer what to do; it does not identify the error. error_type is 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)
false

The 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!/1 and JSON.encode!/1 on an error struct produce to_map/1 — the full record, :env included. So render("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
end

That 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
end

MyApp.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)
422

The 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/1 and retryable?/1 are 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.
  • :env is always nil — it described a location in the sending process.
  • :cause is 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
end

Errata.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
end

Kept 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.