View Source Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[1.7.0] - 2026-08-19
Added
Errata.from_map/3andErrata.from_map!/3(#48), the counterpart toto_map/1: an Elixir application that has an error type compiled can rebuild an error of that type from its encoded form, and get concrete-struct pattern matching and the guards back with it.{:ok, error} = Errata.from_map(MyApp.Orders.OrderNotFound, decoded_json) match?(%MyApp.Orders.OrderNotFound{}, error) #=> trueAccepts the map from
to_map/1directly or the result of decoding its JSON — string and atom keys both work.from_map/3returns{:ok, error} | {:error, reason}, since malformed input is an expected condition where this gets called;from_map!/3returns the error and raises, for payloads from somewhere you control. Passing a module that is not an Errata error type is a programming error and raises in both.The type is an argument, not 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 that Errata's structuralis_error/1guard exists to avoid. This is the same reasoning that keptto_error/2's fallback out of application config in 1.5.0.Atom safety comes from
:reasonsrather than fromString.to_existing_atom/1. A type that declares its reasons is decoded by matching the incoming value against that declared set, so nothing from the wire reachesto_existing_atom/1at all — and a reason whose atom exists but is not declared for this type is refused, which theto_existing_atomapproach would accept and then fail on at construction. Types without declared reasons fall back toto_existing_atom/1, which is why declaring:reasonsis now worth doing on anything that crosses a boundary.A decoded error is a faithful classification, not a faithful reconstruction, and the docs say so plainly:
:kind,http_status/1,severity/1andretryable?/1are recomputed from the type in the receiving application and the encoded values are ignored, so the receiver's own definitions win even when the sender runs an older version;:envis alwaysnil, since it described a location in the sending process;:causeis kept as the decoded value rather than rebuilt; and context redacted on the way out stays redacted.Two deliberate limits. Aggregate types are refused rather than silently losing their members, because each member carries its type only as a name — decode members individually and rebuild with
new/1. And context keys come back as strings by default, because:contextholds arbitrary data and converting it is where an atom-exhaustion risk would live;keys: :existing_atomsconverts the keys that already exist, recursively and best-effort, leaving unknown ones as strings.This completes #48, whose classification half shipped in 1.6.0.
Changed
Errata.log/2andErrata.report/2now include:http_statusin their metadata, alongside the:kind,:reason,:error_type,:code,:severityand:retryablekeys that were already there. A telemetry handler can now tag on the same classification a boundary branches on — a 5xx-rate metric, for instance — without re-deriving the status from:kind.1.6.0 put all five classifications in
to_map/1but left the metadata with four, and that asymmetry was documented as deliberate on the grounds that a log line and a telemetry event are not HTTP responses. That reasoning is still true as far as it goes, but it did not survive the comparison::retryableis derived from:kindin exactly the same way and has always been in the metadata, so "derived, and only meaningful in some contexts" was never the line being drawn. What was left was an omission that had to be explained everywhere the key list appears, which costs more over time than a key some handlers ignore.Additive: metadata is a map (telemetry) and a keyword list (Logger), so existing handlers and formatters are unaffected unless they assert on the exact key set. Types that compute
http_status/1from:reasonor:contexthave that computed value in the metadata, since this dispatches through the same overridable function ascode/1,severity/1andretryable?/1already do.
[1.6.0] - 2026-08-19
Added
The error's classification now travels with it through
Errata.to_map/1, and therefore through theJason.Encoder/JSON.Encoderimplementations (#48). The serialized form gains four keys:kind,http_status,severity, andretryable.Errata's premise is that a boundary can ask any error what status to return, how loudly to log it, and whether retrying is worth attempting. That held only while the error struct was in hand: the moment it was serialized — an API response, a job payload, a message on a queue — the answers were gone, because computing them requires the error's module. Of the five classifications, only
codecrossed the wire. A receiving service had to re-derive the rest from the module name, which the docs correctly tell people not to match on, since it is an implementation detail that moves when the module moves.{ "error_type": "MyApp.Http.RequestFailed", "reason": "timeout", "kind": "infrastructure", "http_status": 503, "severity": "error", "retryable": true }The four keys are computed through the same overridable functions as the accessors, so a type that derives its status or retryability from
:reasonserializes what it actually computed rather than a default. A wrapped:causeand the members of an aggregate serialize through the sameto_map/1, so each carries its own classification instead of inheriting the outer error's.This is deliberately the classification half of #48 and not the deserialization half. Putting the answers on the wire serves a consumer that does not hold the error's module — another service, or a program not written in Elixir — and needs no atom-safety or module-resolution machinery to do it. A
from_map/2that reconstructs the struct serves the opposite case, where the receiving VM already has the module compiled and could recompute the classification anyway; it remains open, now with the cheaper half no longer blocking on it.Additive under SemVer: map patterns are open, so existing matches on
to_map/1still hold. Only an assertion of exact map equality would need updating.
[1.5.0] - 2026-08-18
Added
Errata.to_error/2andErrata.UnknownError(#46), for the errors an application did not define:{:error, :timeout}from a client library, anEcto.Changeset, aDBConnection.ConnectionError. Errata's boundary accessors are strict on purpose —Errata.http_status(:timeout)raises rather than guessing a500— so a fallback controller had one uniform clause and a hand-written one for everything else.Errata.to_error/2is total, and returns an Errata error unchanged so it is safe to apply to a value that may already be normalized:Errata.to_error(:timeout) # an Errata.UnknownError, reason: :timeout, cause: :timeout Errata.to_error(existing_error) # existing_error, unchangedErrata.UnknownErroris the default target, and the first concrete error type Errata itself ships. It is an ordinary:generalerror — a500, not retryable — with the original value kept as its:cause, soroot_cause/1andformat_chain/1still reach it. Passfallback: MyApp.UnexpectedErrorto land in an application's own catch-all instead. There is deliberately no application config for this: a global setting would be the central registry that Errata's structuralis_error/1guard exists to avoid, and it would mean a library callingto_error/1minted the application's error type.This classifies nothing on its own, and is not meant to. A
500is right for a genuinely unknown value and wrong for a changeset (a422) or a connection timeout (a retryable503), soto_error/1is documented as the base case beneath an application's own dispatch function rather than as a replacement for one: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) endAn
Errata.Convertibleprotocol was built for this and then cut before release. Every implementation would have been written by the same application that callsto_error/1— neither Ecto nor Finch is going to depend on Errata to write one, and a library that already uses Errata returns Errata errors — so the open-extension property that justifies a protocol never came into play, while its constraints (one implementation per type, globally, forever) did. Function clauses are the simpler tool when one party owns both sides, and they let two boundaries classify the same value differently. A protocol can be added later without breaking anything, which is the reason to wait rather than guess.to_error/2is a plain function rather than a macro, unlikewrap/3. That makes it capturable (&Errata.to_error/1) at the cost of leaving:envnil — which is the honest result anyway, since normalization happens in a generic boundary function whose location says nothing about where the failure came from.Two details worth knowing: an atom becomes the
:reasonas well as the cause, but only when the target type would accept it, since deriving a reason that a type's:reasonslist rejects would turn the call that exists to stop unknown values escaping into a raise. And{:error, reason}tuples are not unwrapped, since a value that legitimately is a two-tuple cannot be told apart from one that means "error" — match the tuple at the call site instead.Errata.reason/1,Errata.context/1, andErrata.kind/1(#39), completing an accessor set that already hadcode/1,severity/1,http_status/1,retryable?/1,cause/1, anddisplay_message/1.context/1returns%{}rather thannilfor an error created without context, so calling code can treat the result as a map unconditionally, and it returns the unredacted context — redaction applies to what Errata serializes and emits, not to the error in your own hands.These are also the answer to the type-checker interaction the README documented. Measured on Elixir 1.20, the picture is narrower than the issue assumed: field access after a structural guard (
{:error, e} when Errata.is_error(e) -> e.reason) is warning-free, and the one shape that still warns — a variable bound by a barerescue e ->— warns for any exception, not just an Errata one (e.messageon a plainRuntimeErrorwarns identically). So this is ordinary Elixir behaviour rather than something Errata does to you, and the accessors are a plain function call that sidesteps it.The README's info box has been rewritten accordingly, and its
Map.fetch!/2advice dropped — that workaround is not needed. Structural-guard field access is verified warning-free across the whole supported range, 1.15 through 1.20; the bare-rescuewarning appears from 1.17, when the type checker landed. The compile-time behaviour is now pinned by tests, so a future Elixir that changes it will say so.Aggregate errors (#36), for the "several things went wrong at once" shape that validation produces. A type declared
aggregate: truegains an:errorsfield holding member errors:defmodule MyApp.Orders.ValidationFailed do use Errata.DomainError, aggregate: true end ValidationFailed.new(errors: [email_error, age_error])The alternative was modelling it as one error with a list of maps in
:context, which throws away everything the library is for — each sub-failure loses its type, code, HTTP status, severity, and retryability and becomes inert data. An aggregate keeps them as errors: members serialize throughto_map/1and the JSON encoders with their own types and codes, and with their own redaction rules applied.The aggregate is itself an ordinary Errata error, so
is_error/1, raising,{:error, _}tuples, and boundary code all keep working.Errata.errors/1reaches the members and returns[]for an ordinary error, so callers never branch on whether they hold an aggregate;Errata.aggregate?/1asks about the type.The design work was the merge rules, and the three deliberately differ:
severity/1— the most severe member. Severities are totally ordered, so the maximum is unambiguous, and it is what a log level should be.retryable?/1— retryable only if every member is. Retrying helps only if all of it could succeed next time; one permanent failure makes the retry pointless.http_status/1— the members' status if they agree, otherwise the aggregate's own. There is no meaningful maximum over status codes, so picking a "highest" would be arbitrary; unanimity is the only member-derived answer that is never wrong.
An empty aggregate falls back to its own declared values, and each rule stays overridable per type. Members must themselves be Errata errors — a bare map cannot answer those three questions, so anything else raises
ArgumentErrorat construction. SeeErrata.Aggregate.Redaction of sensitive values in error context (#35). Errata encourages capturing arbitrary metadata in
:contextand then ships it outward —to_map/1and the JSON encoding,Errata.log/2as Logger metadata,Errata.report/2as telemetry metadata. There was no way to keep a value out of that path, socontext: %{params: params}put a password in the log aggregator. Unlike the other items on the list this was a safety gap rather than a missing feature: the default behavior was the unsafe one and nothing in the docs said so.A
:redactoption declares a type's sensitive keys:use Errata.DomainError, redact: [:password, :token]Redaction is recursive and matches atom and binary keys alike. This is the point rather than a bonus: the common leak is not
%{password: pw}but a params map captured wholesale, where the sensitive key is nested and has a string key.It applies at the serialization seam, not at creation, so the struct you hold keeps the real values for local debugging. Only what Errata emits is redacted.
config :errata, redact: [...]sets a global floor for every error type. The default is[]— nothing changes shape until an application opts in.The generated
redact_context/1is overridable for rules a key list cannot express, and every serialization seam dispatches through it, so an override applies to all of them rather than the one its author was looking at.Errata.Redactionis public, so custom overrides can reuse the recursive walk.
Added
Generated error types now have an overridable
display_message/1function (#45), so a type can compute its user-facing message from its:reasonor:contextrather than being limited to a static:default_message:defmodule MyApp.Orders.OrderNotFound do use Errata.DomainError, default_message: "the requested order does not exist" def display_message(%{context: %{order_id: id}}), do: "order #{id} does not exist" def display_message(error), do: error.message endErrata.display_message/1andErrata.to_map/1(and therefore the JSON encoding) now dispatch through it, so an override reaches every place a user-facing message is read. This bringsdisplay_message/1in line withhttp_status/1,code/1,severity/1, andretryable?/1, which were already generated-overridable-and-delegated; it was the only one reading the struct field directly. The default returns the:messagefield unchanged, so behavior is unchanged for types that do not override it.
Documentation
The README is split into a short front page plus guides (#40). It was 955 lines, and everything added since 1.0 had landed in one linear page — the cumulative effect overstated what a reader has to learn to start. The front page now covers what Errata is, the quick start, defining error types, creating and raising them, and an index; four new guides under
guides/cover the rest:guides/handling-errors.md— the guards,use Errata, values vs. rescuing.guides/boundaries.md— HTTP status, external codes, severity and retryability, and rendering an error for a user.guides/wrapping-errors.md—wrap/2and cause chains, context enrichment, and aggregates.guides/observability.md—log/2,report/2, the telemetry contract, and redaction.
guides/design.mdgains the "type vs. reason" and "Why Errata?" material alongside the:kindguidance it already held. Doctest coverage moved with the content rather than being lost: the 23 README doctests are now 16 on the front page plus 7 in the guides, run bydoctest_file/1in the newtest/guides_test.exs.The shared doctest fixtures (
MyApp.Orders.*) moved from the top oftest/errata_test.exsintotest/support/my_app.ex, so that any single test file needing them can be run on its own. This also makeselixirc_paths(:test)'s long-standingtest/supportentry point at a directory that exists.A "Dynamic messages" section in the README (#23) showing how to compute a user-facing message from an error's
:reasonor:contextby overridingdisplay_message/1, rather than building the string by hand at every call site. This is the answer to message templating: a plain function and pattern matching, with no template syntax to learn and no missing-key failure mode. The examples are doctests, including the one showing that the override deliberately does not change the developer message thatException.message/1and logs use. The:default_messageoption docs now point at it.
Fixed
to_string/1(theString.Charsimplementation) now respects an overriddenmessage/1(#45). It called the internal message formatter directly, so a type that overrodemessage/1got its custom rendering fromException.message/1,raise, andErrata.log/2, but silently got the default fromto_string/1— despite the two being documented as the same developer-oriented message.to_string/1andException.message/1now always agree.
Documentation
Errata.create/2is now documented as the recommended way to create an error (#37). It captures the same:envas the per-modulecreate/1macro, but because it takes the error type as an argument, a singleuse Erratacovers every error type a module creates — the per-typerequirethatcreate/1needs is never required. The README and theErrata.Errormoduledoc now lead with it.Errata.Error.create/1documents the cost of capturing the environment: on the order of a microsecond per error, and flat with respect to stack depth, since the VM already caps the captured stacktrace at 8 frames. Explicitly not a reason to reach forErrata.Error.new/1.Errata.Error.new/1says what it is actually for, rather than reading as a trap: the cases a macro cannot serve — dynamic invocation viaapply/3, capturing as&SomeError.new/1— plus tests and fixtures, whereenv: nilkeeps error structs easy to compare.A new "Design notes" guide (
guides/design.md, #38) covering the:kindtaxonomy from the user's side: what each kind actually decides, how to choose one, where external-service errors belong, and how to opt out of the taxonomy entirely by defining every type with the baseErrata.Error. Two points it makes plainly that the reference docs did not:kindsupplies defaults forhttp_status/1andretryable?/1only —severity/1andcode/1do not derive from it — and thehttp_status/1default is a starting point that domain errors often override, while theretryable?/1default is usually right. The guide's examples are pinned bytest/errata/design_guide_test.exs, since they are module definitions rather than doctests.
Changed
- The
error/0,domain_error/0, andinfrastructure_error/0types now carry anoptional(:errors)key, so that code matching on an aggregate's members type-checks. This is additive: an ordinary error still matches, and no existing spec becomes invalid. Errata.report/2telemetry metadata now carries the:errorstruct with its context redacted, not just the separate:contextkey (#35). Leaving the raw struct there would have made redaction pointless in the case it exists for — a handler forwardingmetadata.errorto an external service would ship the unredacted context. The struct is otherwise untouched: same type, same reason, still pattern-matchable and re-raisable. This only affects error types that declare:redactkeys or applications that set the global config; with neither, metadata is unchanged.
[1.4.0] - 2026-07-31
Added
- Stable external error codes. An error type can declare a
:code(such as"ORDER_NOT_FOUND") that is independent of its Elixir module name, giving external consumers — API clients, i18n catalogs, support tooling — an identifier that survives renaming or moving the module. Retrieve it withErrata.code/1or the generated per-modulecode/1, which is overridable so a type can derive a code from the error's:reasonor:context. Codes are opt-in with no default: a type that does not declare one returnsnil, since deriving a code from the module name would reintroduce the coupling the option exists to break. (#22) - Severity and retryability classification on error types. (#24)
Errata.severity/1returns an error's severity as aLoggerlevel, set per type with the:severityoption. It defaults to:errorfor every kind, so nothing is reclassified unless a type opts in.Errata.retryable?/1returns whether an error is likely transient, set per type with the:retryableoption and defaulting off the error's kind::infrastructureerrors are retryable,:domainand:generalerrors are not. Errata provides no retry mechanism of its own — this is a classification for your own retry logic to branch on.- Both are generated as overridable per-module functions (
severity/1andretryable?/1), following the same pattern ashttp_status/1, so a type can compute either from the error's:reasonor:context. Neither adds a field to the error struct or to theto_map/1/ JSON shape.
Changed
Errata.to_map/1(and therefore the JSON encoding) now includes acodekey, which isnullfor error types that do not declare a:code. This is additive to the serialized shape — the key is always present, consistent with the existingmessage,cause, andenvkeys, which are likewise emitted when empty. Consumers that ignore unknown keys are unaffected. (#22)Errata.log/2now logs at the error'sseverity/1when no level is given, andErrata.report/2withlog: truedoes the same. Since severity is:errorunless a type sets one, this is backward compatible for existing error types. (#24):code,:severity, and:retryableare now included in the metadata emitted byErrata.log/2(as Logger metadata) andErrata.report/2(as top-level[:errata, :error]telemetry metadata), so handlers can route or alert on them. (#22, #24)
Fixed
- The
Errata.error/0type declaredenv: Errata.Env.t(), but an error created withnew/1has no environment. It is nowErrata.Env.t() | nil, matchingErrata.domain_error/0andErrata.infrastructure_error/0and the actual behavior.
[1.3.0] - 2026-06-04
Added
use Errata— a convenience macro for modules that handle or create Errata errors. It imports the three guards (is_error/1,is_domain_error/1,is_infrastructure_error/1) so they can be used unqualified inwhenclauses and function heads, and (becauseimportimpliesrequire) makes theErrata.create/2andErrata.wrap/3macros callable. Only the guards are imported; the rest of the API stays qualified. This is distinct fromuse Errata.Error, which defines a new error type.
[1.2.0] - 2026-06-04
Added
Errata.wrap/2andErrata.wrap/3macros, which wrap a cause in an error of any type while capturing the current__ENV__and stacktrace — the convenience counterpart to the per-modulewrap/2macro, mirroringErrata.create/2. This lets a module wrap causes for several error types without a separaterequirefor each one.
[1.1.0] - 2026-06-03
Added
- Native JSON support: on Elixir 1.18 and later, every error type now implements
the built-in
JSON.Encoderprotocol, soJSON.encode!(error)works with no third-party dependencies. The built-in and Jason backends produce the same JSON shape. (#30)
Changed
jasonis now an optional dependency. Projects that have Jason continue to get a generatedJason.Encoderimplementation exactly as before; projects on Elixir 1.18+ that don't use Jason can now drop it and rely on the built-inJSONencoder. This is backward compatible — anyone who depends onJason.encode!(error)already has Jason in their own dependencies. (#30)
Upgrading
- If your project calls
Jasondirectly but relied on Errata to pull it in transitively, add{:jason, "~> 1.4"}to your own dependencies, since Errata no longer forces it into your dependency tree. On Elixir 1.18+ you can instead use the built-inJSONmodule and drop the Jason dependency entirely.
[1.0.0] - 2026-06-03
First stable release. As of 1.0.0 the public API — the error struct shape, the
Errata guards and helper functions, the generated Errata.Error callbacks, and
the to_map/1 / JSON and [:errata, :error] telemetry shapes — is covered by
Semantic Versioning.
Added
- Context enrichment:
Errata.put_context/3andErrata.merge_context/2add to an error's:contextas it propagates, so intermediate layers can attach context the creation site did not have without rebuilding the struct. (#18) - Declared reasons: error types can now enumerate their valid reasons with the
:reasonsoption (use Errata.DomainError, reasons: [...]). Creating an error with a reason outside the declared set raises anArgumentError(anilreason is always allowed); a:default_reason, if given, must be one of the declared reasons; and areason/0type enumerating them is generated for the docs. (#20) - Error reporting:
Errata.log/2logs an error at a given level with itsreason,kind,context, and origin attached as structured Logger metadata;Errata.report/2emits a[:errata, :error]telemetry event (and optionally logs), providing a vendor-neutral seam for forwarding errors to Sentry, metrics, etc. via a telemetry handler in your application. Adds atelemetry ~> 1.0dependency. (#19) - HTTP status mapping: each error type now has a generated, overridable
http_status/1function (and a matchingErrata.http_status/1) that defaults off the error's kind (:domain→422,:infrastructure→503,:general→500). Set a specific status with the:http_statusoption, or override the function to compute one from the error. No web-framework dependency is added. (#21)
[0.10.0] - 2026-06-02
Added
- Error wrapping (chaining): error types can now carry a
:cause— the original error, exception, or value that led to them — without losing the context of the underlying failure.- A generated
wrap/1,2macro on each error module wraps a caught error as the:causeof a new error, capturing the current__ENV__(likecreate/1) and, when givenstacktrace: __STACKTRACE__, the original error's stacktrace. new/1,create/1, andraise/2now also accept a:causeparam.- The cause is stored as an
Errata.Causestruct (kind/value/stacktrace). Errata.cause/1returns the immediate cause;Errata.root_cause/1walks the chain to the deepest cause;Errata.format_chain/1renders the fullCaused by:chain for logging.to_map/1(and JSON) now include the cause, recursing into wrapped Errata errors and rendering standard exceptions by type and message.
- A generated
[0.9.0] - 2026-06-02
Added
Errata.create/2macro to create an error of any type while capturing the current env, without a separaterequirefor each error module. (#4)Errata.to_map/1to convert any Errata error to a plain, JSON-encodable map without needing to know the error's specific module. (#5)Errata.display_message/1to retrieve the bare, human-readable:messageof an error (without the:reasonsuffix thatException.message/1appends), for rendering errors to end users. (#7)
Changed
- Breaking:
new/1,create/1, andraise/2now raise anArgumentErrorwhen given unrecognized param keys instead of silently ignoring them. Only:message,:reason, and:contextare accepted. Callers that previously relied on extra keys being dropped will need to remove them. (#3)
Fixed
- Serialized error maps (
to_map/1) and their JSON form no longer leak theElixir.prefix on module names:error_typeandenv.moduleare now rendered as e.g."MyApp.Foo"(as strings rather than raw atoms), andenv.file_lineno longer includes a trailing colon. (#6)