defmodule Errata do # Pull in the moduledocs from the demarcated section of the README file @external_resource Path.expand("./README.md") @moduledoc File.read!(Path.expand("./README.md")) |> String.split("") |> Enum.at(1) |> String.split("") |> List.first() @typedoc """ Type to represent the various kinds of Errata errors. """ @type error_kind :: :domain | :infrastructure | :general | nil @typedoc """ 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 :: %{ __struct__: module(), __exception__: true, __errata_error__: true, kind: Errata.error_kind(), message: String.t() | nil, reason: atom() | nil, context: map() | nil, cause: Errata.Cause.t() | nil, env: Errata.Env.t() } @typedoc """ Type to represent Errata domain errors. """ @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 } @typedoc """ Type to represent Errata infrastructure 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 } @doc """ Returns `true` if `term` is any Errata error type; otherwise returns `false`. Allowed in guard tests. """ defguard is_error(term) when is_struct(term) and is_exception(term) and is_map_key(term, :__errata_error__) and :erlang.map_get(:__errata_error__, term) == true and is_map_key(term, :kind) and :erlang.map_get(:kind, term) in [ :domain, :infrastructure, :general ] and is_map_key(term, :message) and is_map_key(term, :reason) and is_map_key(term, :context) and is_map_key(term, :cause) and is_map_key(term, :env) @doc """ Returns `true` if `term` is an Errata domain error type; otherwise returns `false`. Allowed in guard tests. """ defguard is_domain_error(term) when is_error(term) and :erlang.map_get(:kind, term) == :domain @doc """ Returns `true` if `term` is an Errata infrastructure error type; otherwise returns `false`. Allowed in guard tests. """ defguard is_infrastructure_error(term) when is_error(term) and :erlang.map_get(:kind, term) == :infrastructure @doc """ 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 `c: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 end Compare 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 """ defmacro create(error_module, params \\ Macro.escape(%{})) do quote do {:current_stacktrace, [_process_info_call | stacktrace]} = Process.info(self(), :current_stacktrace) Errata.Errors.create(unquote(error_module), unquote(params), __ENV__, stacktrace) end end @doc """ Converts any Errata error to a plain, JSON-encodable map. This is the generic counterpart to the per-type `c: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} Raises an `ArgumentError` if `error` is not an Errata error. """ @spec to_map(error()) :: map() def to_map(error) when is_error(error), do: Errata.Errors.to_map(error) def to_map(other) do raise ArgumentError, "expected an Errata error, got: #{inspect(other)}" end @doc """ 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. 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. """ @spec display_message(error()) :: String.t() | nil def display_message(error) when is_error(error), do: error.message def display_message(other) do raise ArgumentError, "expected an Errata error, got: #{inspect(other)}" end @doc """ 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 `c:Errata.Error.wrap/2` macro, or by passing a `:cause` to `c:Errata.Error.new/1` or `c: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)) nil Raises an `ArgumentError` if `error` is not an Errata error. """ @spec cause(error()) :: term() | nil def cause(error) when is_error(error) do case error.cause do %Errata.Cause{value: value} -> value nil -> nil end end def cause(other) do raise ArgumentError, "expected an Errata error, got: #{inspect(other)}" end @doc """ Walks the cause chain of `error` and returns the deepest (root) cause, or `nil` if `error` has no cause. When a wrapped cause is itself an Errata error carrying its own cause, the chain is followed to the bottom. iex> alias MyApp.Orders.{OrderNotFound, PaymentDeclined} iex> require OrderNotFound iex> require PaymentDeclined iex> root = %RuntimeError{message: "db down"} iex> inner = OrderNotFound.wrap(root, reason: :lookup_failed) iex> outer = PaymentDeclined.wrap(inner, reason: :declined) iex> Errata.root_cause(outer) %RuntimeError{message: "db down"} Raises an `ArgumentError` if `error` is not an Errata error. """ @spec root_cause(error()) :: term() | nil def root_cause(error) when is_error(error) do case cause(error) do nil -> nil value -> if is_error(value), do: root_cause(value) || value, else: value end end def root_cause(other) do raise ArgumentError, "expected an Errata error, got: #{inspect(other)}" end @doc """ 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. """ @spec format_chain(error()) :: String.t() def format_chain(error) when is_error(error) do head = "#{inspect(error.__struct__)}: #{Exception.message(error)}" case error.cause do nil -> head %Errata.Cause{} = cause -> head <> "\nCaused by: " <> format_cause(cause) end end def format_chain(other) do raise ArgumentError, "expected an Errata error, got: #{inspect(other)}" end defp format_cause(%Errata.Cause{value: value}) when is_error(value), do: format_chain(value) defp format_cause(%Errata.Cause{kind: kind, value: value, stacktrace: stacktrace}) do Exception.format(kind, value, stacktrace || []) end end