View Source Errata.Error behaviour (Errata v1.9.0)

Support for creating custom error types, which can either be returned as error values or raised as exceptions.

Errata errors can be defined by creating an Elixir module that uses the Errata.Error module. Error types defined in this way are Elixir Exception structs with the following keys:

  • message - human readable string describing the nature of the error
  • reason - an atom describing the reason for the error, which can be used for pattern matching or classifying the error
  • context - a map containing arbitrary contextual information or metadata about the error

Note the distinction between two ways of rendering an error as a string. Exception.message/1 (and the String.Chars implementation) return a developer-oriented message that combines message and reason (for example, "the requested order does not exist: :not_found") — useful in logs and raised-exception output. Errata.display_message/1 returns just the human-readable message, intended for rendering to end users.

Because these error types are defined with defexception/1, they can be raised as exceptions with raise/2. However, because they implement the Errata.Error behaviour, it is also possible to create instances of these error structs using the generated implementations of Errata.Error.new/1 or Errata.Error.create/1 and use them as return values from functions, either directly or wrapped in an error tuple such as {:error, my_error}.

Error types defined with Errata.Error are of kind :general by default. Since most errors are either domain errors or infrastructure errors, prefer Errata.DomainError or Errata.InfrastructureError (which share all of the functionality described here) when defining custom error types, and use Errata.Error directly only for general errors that fit neither category, such as errors originating in library code.

Usage

To define a new custom error type, use/2 the Errata.Error module in your own error module:

defmodule MyApp.UnexpectedError do
  use Errata.Error,
    default_message: "an unexpected error occurred"
end

use Errata.Error

When you use Errata.Error, the Errata.Error module will define an exception struct with defexception/1 and will generate an implementation of the Errata.Error behaviour.

The following options may be provided to use Errata.Error. The list is closed: an option that is misspelled or unrecognized raises ArgumentError at compile time rather than being silently ignored, since a use option is written once and a misconfiguration would otherwise be permanent and invisible.

  • :default_reason - the default value to use for the :reason field if it is not provided

  • :default_message - the default value to use for the :message field if it is not provided. This is a static string; to compute a user-facing message from the error's :reason or :context (naming the particular order or item, say), override the generated display_message/1 function instead — Errata.display_message/1 and to_map/1 both dispatch through it. See Errata.display_message/1.

    A type that declares no :default_message renders as nil through every display path. To give every such type a floor rather than repeating a fallback at each boundary, set an application-wide default:

    config :errata, default_display_message: "an unexpected error occurred"

    It applies only where the type declares nothing and the caller passed no :message, and defaults to nil, which is the historical behaviour. This mirrors config :errata, redact: — a global floor that individual types refine.

  • :reasons - an optional list of atoms enumerating the valid reasons for this error type. When given, creating an error (via new/1, create/1, wrap/2, or raise/2) with a :reason outside this set raises an ArgumentError. A nil (unspecified) reason is always allowed, and a :default_reason, if also given, must be one of the declared :reasons. Declaring reasons also generates a reason/0 type enumerating them, so the valid reasons are visible in the generated documentation.

  • :http_status - the HTTP status code to associate with this error type, returned by the generated http_status/1 function (and Errata.http_status/1). When omitted, the status defaults off the error's kind (:domain422, :infrastructure503, :general500). The generated http_status/1 is overridable, so it can instead be defined to compute a status from the error's :reason or :context.

  • :code - a stable external code for this error type (such as "ORDER_NOT_FOUND"), returned by the generated code/1 function (and Errata.code/1) and included in to_map/1. A code is independent of the module name, so it remains a valid contract with external consumers even if the module is renamed or moved. There is no default: types that do not declare one return nil. The generated code/1 is overridable, so it can instead be defined to derive a code from the error's :reason or :context.

  • :severity - the severity of this error type, as a Logger.level/0, returned by the generated severity/1 function (and Errata.severity/1). Defaults to :error for every kind. This is the level at which Errata.log/2 logs the error when no level is given explicitly, and it is included in the metadata emitted by Errata.log/2 and Errata.report/2. The generated severity/1 is overridable, so it can instead be defined to compute a severity from the error's :reason or :context.

  • :retryable - whether errors of this type are retryable, returned by the generated retryable?/1 function (and Errata.retryable?/1). When omitted, this defaults off the error's kind: :infrastructure errors are retryable, :domain and :general errors are not. The generated retryable?/1 is overridable, so it can instead be defined to decide from the error's :reason or :context.

  • :redact - a list of context keys whose values are sensitive, replaced with "[REDACTED]" everywhere Errata serializes the context: to_map/1 and the JSON encoding, Errata.log/2 metadata, and Errata.report/2 telemetry metadata. Redaction is recursive and matches atom and binary keys alike, so redact: [:password] covers a password nested inside a captured params map with string keys. The error struct keeps the real values, so they remain available locally for debugging. Defaults to []; add a global floor with config :errata, redact: [...]. The generated redact_context/1 is overridable for rules a key list can't express. See Errata.Redaction.

  • :aggregate - when true, this type can hold member errors, for the "several things went wrong at once" shape that validation produces. Adds an :errors field (a list of Errata errors, empty by default) that new/1 and create/1 accept, includes the members in to_map/1 and the message, and merges severity/1, retryable?/1, and http_status/1 across them — each by a different rule, and each still overridable. Members must themselves be Errata errors. Defaults to false. See Errata.Aggregate.

  • :kind - the "kind" of Errata error to create, one of :domain, :infrastructure, or :general (which is the default). Accepted only here: use Errata.DomainError and use Errata.InfrastructureError set the kind themselves and reject the option.

The :kind option

Although it is possible to define domain error types or infrastructure error types by using :domain or :infrastructure as the :kind option, it is preferred to instead define these types of errors with use Errata.DomainError or use Errata.InfrastructureError. This approach is more explicit and allows for easier identification of domain errors and infrastructure errors within an application.

To create instances of the error--to use as an error return value from a function, say--the recommended path is Errata.create/2, which captures the current __ENV__ and stacktrace into the :env field. Because it takes the error type as an argument, a single use Errata covers every error type the module creates, with no per-type require:

defmodule MyApp.SomeModule do
  use Errata

  alias MyApp.UnexpectedError

  def some_function(arg) do
    {:error, Errata.create(UnexpectedError, reason: :unexpected, context: %{arg: arg})}
  end
end

The generated create/1 does the same thing and reads more directly when a module works mostly with one error type, at the cost of a require for that module, since the callback is implemented as a macro:

defmodule MyApp.SomeModule do
  require MyApp.UnexpectedError, as: UnexpectedError

  def some_function(arg) do
    {:error, UnexpectedError.create(reason: :unexpected, context: %{arg: arg})}
  end
end

new/1 is a plain function that builds the error without environment info. See new/1 for when that is the right choice.

To raise errors as exceptions, simply use raise/2 passing extra params as the second argument if desired:

defmodule MyApp.SomeModule do
  require MyApp.UnexpectedError, as: UnexpectedError

  def some_function!(arg) do
    raise UnexpectedError, reason: :unexpected, context: %{arg: arg}
  end
end

The generated t/0 type

Every generated error type gets a t/0 type, but it is the kind-level type rather than one naming the struct:

@type t() :: Errata.domain_error()

So every domain error type has a literally identical t/0, and a spec written as @spec refund(Order.t(), PaymentDeclined.t()) :: :ok accepts any domain error. To write a spec that names one error type, use the struct form instead:

@spec refund(Order.t(), %PaymentDeclined{}) :: :ok

This is the opposite of the usual Elixir convention, where t/0 means "this module's type", so it is worth knowing which of the two you are reaching for.

The reason/0 type generated from :reasons is specific — it enumerates the declared values — so a spec written against PaymentDeclined.reason() gets real checking.

Dialyzer's :extra_return flag

The generated http_status/1, code/1, severity/1 and retryable?/1 carry behaviour-level specs while their default bodies return a compile-time literal. A type declaring code: "ORDER_NOT_FOUND" therefore has success typing <<_::176>> against a spec that also admits nil, and one that never overrides retryable?/1 has success typing false against boolean(). With flags: [:extra_return], Dialyzer reports an extra_range warning for each, and the count grows with every error type an application defines.

Narrowing the specs per type would trade this warning for a worse one: a type declared retryable: false would get @spec retryable?(...) :: false, and the first override returning true for a particular reason — the extension point these functions exist for — would then be the thing Dialyzer flagged, in user code. :extra_return is best left off in a project that uses Errata.

Summary

Types

Type to represent allowable keys to use in params used for creating error structs.

Type to represent allowable values to be passes as params for creating error structs.

t()

Type to represent Errata error structs.

Callbacks

Invoked to create a new instance of an error struct with default values and the current __ENV__.

Invoked to create a new instance of an error struct with the given params and the current __ENV__.

Invoked to create a new instance of an error struct with default values.

Invoked to create a new instance of an error struct with the given params.

Invoked to convert an error to a plain, JSON-encodable map.

Invoked to wrap an existing error, exception, or arbitrary value as the :cause of a new error struct, capturing the current __ENV__.

Invoked to wrap an existing error as the :cause of a new error struct, with the given opts, capturing the current __ENV__.

Types

@type param() :: :message | :reason | :context | :cause

Type to represent allowable keys to use in params used for creating error structs.

See also params/0.

@type params() :: Enumerable.t({param(), any()})

Type to represent allowable values to be passes as params for creating error structs.

This effectively allows for using either a map or keyword list with allowable keys defined by param/0.

@type t() :: Errata.error()

Type to represent Errata error structs.

Error structs 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.

Callbacks

@macrocallback create() :: Macro.t()

Invoked to create a new instance of an error struct with default values and the current __ENV__.

See create/1.

@macrocallback create(params()) :: Macro.t()

Invoked to create a new instance of an error struct with the given params and the current __ENV__.

Since this is a macro, the __ENV__/0 special form is used to capture the Macro.Env struct for the current environment and the public fields of this struct are placed in the exception struct under the :env key. This provides access to information about the context in which the error was created, such as the module, function, file, and line. See t:env/0 for further details.

Note that because this is a macro, callers must require/2 the error module to be able to use it. Errata.create/2 avoids that per-module require — it takes the error type as an argument, so a single use Errata (or require Errata) covers every error type a module creates, with the same :env capture. Prefer it when a module works with several error types.

Capturing the environment walks the process stack, which costs on the order of a microsecond per error — negligible against almost any operation that can fail, including in with chains at request volume. The stacktrace is already capped by the VM (8 frames by default), so the cost does not grow with stack depth. Reach for new/1 only when you need a plain function, not to avoid this cost.

@callback new() :: t()

Invoked to create a new instance of an error struct with default values.

See new/1.

@callback new(params()) :: t()

Invoked to create a new instance of an error struct with the given params.

Unlike create/1, this leaves the :env field nil: it records nothing about where the error was created. Prefer create/1 or Errata.create/2 unless you need one of the things a macro cannot do — calling it dynamically with apply/3, or capturing it as &SomeError.new/1 to pass around. It is also convenient in tests and fixtures, where env: nil keeps error structs easy to compare.

@callback to_map(t()) :: map()

Invoked to convert an error to a plain, JSON-encodable map.

@macrocallback wrap(cause :: Macro.t()) :: Macro.t()

Invoked to wrap an existing error, exception, or arbitrary value as the :cause of a new error struct, capturing the current __ENV__.

This is the idiomatic way to translate a lower-level failure into a structured Errata error without losing the context of the original. It is equivalent to create/1 with the given cause placed in the :cause field. See wrap/2 to also provide params (such as a :reason) and the original stacktrace.

Like create/1, this is a macro, so callers must require/2 the error module.

@macrocallback wrap(cause :: Macro.t(), opts :: Macro.t()) :: Macro.t()

Invoked to wrap an existing error as the :cause of a new error struct, with the given opts, capturing the current __ENV__.

In addition to the standard params accepted by create/1 (:message, :reason, :context), opts may include:

  • :stacktrace - the stacktrace where the original error occurred, typically __STACKTRACE__ from within a rescue/catch clause
  • :kind - the kind of the wrapped error, one of :error (the default), :throw, or :exit

The wrapped value is stored as an Errata.Cause in the :cause field, and can be retrieved with Errata.cause/1. The typical use is to translate a rescued exception while preserving its original stacktrace:

try do
  Jason.decode!(payload)
rescue
  e ->
    {:error, MyApp.InvalidPayload.wrap(e, stacktrace: __STACKTRACE__, reason: :malformed_json)}
end

Like create/1, this is a macro, so callers must require/2 the error module.