Errors: dossiers, not log lines¶
You know the pipeline through effects, coeffects, frames, and flows. Now: what happens when one of them breaks?
You've caught a hundred errors with console.error("something broke", e). It kept
the message and the stack — and threw away the half that actually matters: which
event was in flight, which frame owned it,
which handler was running, what the
pipeline run had already done. Every one of those facts existed
in the runtime, microseconds before the catch. Then the catch kept two of them, and
you rebuilt the rest by hand.
re-frame2's rule is one sentence: if the runtime knows it, the error record carries it.
Read a dossier (:op-type, :operation, :recovery), and learn the four
degradation defaults (handler throw, missing event, missing fx, missing cofx). The
catalogue is consult-not-memorise; production shipping is the how-to
Report errors in production.
Every error the framework detects becomes a structured map — fat on purpose — with the causal context already attached. This page teaches you to read that map, predict how your app degrades after each kind of failure, fix the error you'll hit first, and test the structure instead of the string.
The dossier¶
When something breaks, the runtime is standing right there. It has the event in hand, the event handler on the hook, the owning frame in scope. It keeps all of it. Here's the shape:
{:id 42 ;; unique trace id
:op-type :error ;; severity — the universal discriminator
:operation :rf.error/handler-exception ;; category — a namespaced keyword
:recovery :no-recovery ;; what the runtime did next
:time 1781078400456 ;; emit time
:source :ui ;; what triggered the run
:rf.trace/trigger-handler ;; the in-scope handler — its value is
{:kind :event ;; the map below, holding the handler's
:id :cart/add-item ;; registration-site coordinates
:source-coord {:ns myapp.cart :file "src/myapp/cart.cljs" :line 142}}
:rf.trace/call-site ;; where the `dispatch` was written
{:file "src/myapp/cart_view.cljs" :line 88}
:tags {:category :rf.error/handler-exception
:failing-id :cart/add-item
:handler-id :cart/add-item
:phase :before ;; the chain phase that threw
:rf.event/v [:cart/add-item {:id 7}]
:frame :app/main
:reason "Event handler `:cart/add-item` threw: ..."
:exception-message "Cannot read properties of undefined (reading 'price')"}}
That's not a log line. It's a map with a fixed shape: severity, category, recovery,
handler registration site, dispatch call-site, and a schema-checked :tags payload.
Tools can filter it; string scrapers can't.
Three fields do most of the work:
:op-typeis severity.:errorfor genuine failures,:warningfor misuse the runtime recovers from. Want "show me everything that failed"? Filter on:op-type :error— you don't need a single category name for that.:operationis the category — a namespaced keyword like:rf.error/handler-exceptionor:rf.error/no-such-fx. Narrow with an exact match.:recoveryis what the framework did after the error. That tells you whether the app is still standing — usually the first thing you want to know.
Two more slots turn a debugging session into a click.
:rf.trace/trigger-handler carries the file and line where the failing handler was
registered; :rf.trace/call-site carries the line of the
dispatch that triggered the
pipeline run. Tools render both as jump-to-source links.
Everything else rides under :tags, with one fixed, schema-checked payload shape per
category. The payload is self-contained on purpose: :category restates
:operation, and :failing-id names the culprit (here the handler itself, which is
why it matches :handler-id).
Coming from Sentry + React error boundaries?
Two anchors will help. Sentry's structured events: an error is a rich record, not a string. React error boundaries: failures land in one designated place. Two things work differently here. You instrument nothing — the framework emits the structured record itself, with the causal context already attached, where Sentry needs you to wrap and tag by hand. And there's no boundary component to catch and steer: what the runtime does after each kind of error is fixed per category, so your code observes the failure rather than deciding what to do about it.
Going deeper — it's a tagged union
The dossier is a single product type with a closed tag set per category — a sum
type keyed on :operation, where each variant carries a statically-known
:tags payload. That's why a consumer can pattern-match on :operation and
trust the shape of what follows: the catalogue is the type definition. The
:op-type/:operation pair is a coarse-then-fine discriminator — severity first
(for the "did anything fail?" fold), category second (for per-variant handling) —
so you consume at whichever granularity your tool needs.
One expectation before you get attached: the dossier is a development surface. Production builds elide the trace stream — not disable it, elide it: dead-code elimination compiles the code out of the release bundle, dossiers and all. Errors that must still reach a monitor in production travel a separate always-on channel carrying a tight record — covered at the end of this page.
A catalogue you consult, not memorise¶
How many categories are there? Dozens — covering handlers, subscriptions, effects, coeffects, schemas, frames, routing, machines, SSR. Don't memorise them. The set grows but never breaks, and it has a grammar you can learn in three bullets.
The prefix names the owning subsystem:
:rf.error/*— a genuine failure.:rf.warning/*— recoverable misuse.:rf.fx/*/:rf.cofx/*/:rf.ssr/*/:rf.epoch/*— not errors but lifecycle events from those subsystems, riding the same envelope (a platform-skipped effect, a hydration mismatch).
Existing categories are never renamed or repurposed: pin a test to
:rf.error/no-such-fx and it still means that next year.
The catalogue itself — every category, its trigger, its :tags payload, its recovery
default — is a closed reference table. You never have to leave the failure in front of
you to read it: every dossier names its own catalogue entry. The record carries
:operation (the category), restates it under :tags :category, states what the
runtime did next in :recovery, and hands you a one-sentence :reason — so the
record is the catalogue row for the error you're holding. Treat it like HTTP status
codes: nobody memorises the table; you look a category up when you meet it, and after
a while you just know the ones you've met.
Recovery is typed — and it isn't yours¶
When an error fires, the runtime applies a typed, per-category default. That's the whole story. There is no app-steering error policy: no global error handler, no hook that swallows an exception or substitutes a result.
Why so rigid? Because the two things such a hook gets used for are both mistakes.
Swallowing an exception masks a bug. Fabricating a replacement result invents
something the thrown handler could not have produced. Genuine recovery belongs at the
source, for expected failures, where "recovery" actually means something:
managed HTTP's :retry for a flaky network, or a defensive
default at a read. And the framework never re-runs a failing handler behind your back
— when you want to try again, you dispatch a fresh event.
The :recovery vocabulary is small and readable on sight:
:no-recovery— the operation did not complete.:replaced-with-default— an unresolved input is substituted (anilfor a missing sub input, say) and the body runs on.:logged-and-skipped— the offending input is dropped; siblings still apply.:warned-and-replaced— two writes conflict over the same target and the last one wins; advisory only.:skipped— a platform-gated effect documented out, not really an error.:fix-registration— the "you registered it wrong, here's how" verb covering typos and badreg-*arguments (areg-subhanded a malformed argument shape, a resource subscribed before it's registered, areg-viewmissing its args vector).
Plus a few category-specific verbs like :supply-frame on the missing-frame error
below. Every category carries its assigned recovery verb in the dossier itself — read
it off the record.
Four of those defaults shape how your app degrades, so they're worth knowing by heart:
- A throwing event handler halts its run with no half-applied state.
:rf.error/handler-exceptionmeans no:dbcommit and no:fx. app-db is exactly as it was before the dispatch. - A dispatch to an unregistered event id is a traced no-op.
:rf.error/no-such-handler(recovery:replaced-with-default— a do-nothing handler stands in) lets a feature module with a botched load order boot degraded instead of crashing. The trace names the exact id, so the bug announces itself instead of showing up as "huh, the button does nothing." - A missing fx drops only itself. Read this one twice — people get it backwards.
:rf.error/no-such-fxdoes not halt the run — the handler's:dbchange still applies and the sibling:fxentries still fire. - A missing coeffect fails loud, before the handler runs.
:rf.error/unregistered-cofx. We'll see why this is the strict sibling of the missing-fx case below.
No app-policy error hook
There is no reg-event-error-handler — on purpose, and this section just argued
why. Observation lives on a listener on the error channel
— the :errors stream covered at the end of this page. Coming from a v1 app that
installed an error hook, the migration guide maps the
translation.
One category, three modes
:rf.error/no-such-handler covers three
registrar misses, discriminated by a mandatory :kind
tag: :kind :event (the dispatch case above), :kind :route (a URL that matched
no registered route pattern — see
routing), and :kind :frame (a tool surface addressing
a frame-id that isn't registered). Filter on the operation keyword alone for a
single "registrar miss" view; route on :kind when you want per-mode handling.
Gotcha — the schema checks you declare fire only in dev
If you guard an app-db path or an event with a
schema, a value that fails it emits
:rf.error/schema-validation-failure to surface the bug early, where the bad
value was produced. Recovery is not uniform — it follows the boundary named
in the :where tag, and the list here is the full map (all eight boundaries the
one category spans): an :event failure skips the handler; an :app-db or
:machine-data failure rolls the run back (no commit); an :fx-args failure
skips just the offending fx (siblings run); a :sub-return or
:sub-override failure surfaces nil and renders on; :flow-output /
:machine-output are observational — the value still commits. Read the
recovery straight off the :where tag on the record; :explain carries the
Malli explanation. The surprise is the asymmetry: a production build
elides the validators you declared over your own code —
which is most of that list — so for those boundaries the category fires only in
dev. Treat a reg-app-schema guard as a development assertion that hardens your
data at its source, not a runtime gate you can lean on in production — a candidate
that violates one installs, silently, in a release build. (A structurally broken
Malli form is the separate :rf.error/malformed-schema, which fails closed and
rolls the commit back.)
One arm of this category does survive a production build, and it is the one you
would reach for. An event handler registered with {:interceptors
[:rf.schema/at-boundary]} is the framework's answer for untrusted structured
ingress — an HTTP request body, a websocket frame, a query string. That is no
longer a claim you are making about your own code; it is the framework promising
something about its own doorway, and a promise kept only in dev is not a promise.
So the check is ungated: it runs in every build. The rejection is real in production, with the
same recovery as any other :where :event failure — the handler is skipped and
the bad payload never reaches app-db. The report survives with it, on both
always-on axes: one :rf.error/schema-validation-failure record tagged
:source :boundary on the :errors stream, and :outcome :rejected on that
dispatch's :events record. So this is the one member of the category you can
alert on from a release build.
What the production record omits is the payload — no event vector, no offending
value, no :explain, not even the interpolated :reason. The nine slots it does
carry (:error, :where, :source, :event-id, :failing-id, :schema-id,
:frame, :recovery, :time) are identifiers, and the omission is deliberately
stricter than redaction: the natural detail of a validation failure is the value
that failed, and a boundary value is attacker-controlled or user-private by
definition, so it may carry secrets under keys the declared schema never named —
the keys a schema-aware redactor cannot know to scrub. The rich diagnosis stays on
the dev trace. The other schema surface that runs in every build is the
managed-HTTP :decode schema, and it is not this category at all — a 2xx body that
fails its schema classifies as :rf.http/decode-failure (carrying
:schema-validation-failure? true) and the request fails, in production as in dev.
One boundary is not on that list on purpose. A recordable coeffect
(:rf.cofx/requires) whose supplied, replayed, or generated value fails its
reg-cofx :schema is not a :where :cofx schema-validation trace. It is
the separate, halting :rf.error/cofx-value-invalid — a production hard error
that throws (:recovery :no-recovery) in prod as well as dev, because a
recordable coeffect rides the durable causal record and a bad one silently
corrupts replay, SSR, and Xray (the dev inspector). Look the
:rf.error/cofx-value-invalid row up in the catalogue when you meet it, and see
coeffects for why a coeffect value must stay recordable.
The failures you'll actually meet¶
Read each category through the production incident it describes. Here are the four you'll meet first; the rest read the same way once you have the rhythm.
A dispatch with no frame in scope¶
You wrote a quick (rf/dispatch [:cart/add-item {:id 7}]) at the REPL, or in a
setTimeout callback, or in a promise .then — and instead of a run you got an
error. Nearly everyone trips on this once.
dispatch and
subscribe resolve their target
frame from the surrounding scope, but a deferred callback runs
on a fresh stack, long after that scope has unwound. The runtime does not guess a
default — frame identity is carried, not found.
It emits :rf.error/no-frame-context (recovery: :supply-frame) and dispatches
nothing.
The fix is always the same: carry the frame across the async gap — capture
:frame at fx-handler entry and pass it explicitly on the deferred dispatch
({:frame frame} in the opts), exactly as Effects showed.
capture-frame is the same move for app code, and Frames is the
pattern's canonical home. In a view you're already under the
frame-provider, so you rarely think about it; at the
REPL or in a test, with-frame / with-new-frame pin the scope.
A handler throws¶
The user arrived on a deep link that bypassed your init event, so :cart was never
seeded. The first click walks update-in straight into a nil and throws.
(rf/reg-event :cart/add-item
(fn [{:keys [db]} [_ item]]
{:db (update-in db [:cart :items] conj item)})) ;; throws when :cart doesn't exist
The runtime catches it and emits :rf.error/handler-exception with
:recovery :no-recovery: run halted, nothing committed, app-db untouched. The fix is
a defensive default at the point of access:
(rf/reg-event :cart/add-item
(fn [{:keys [db]} [_ item]]
{:db (update-in db [:cart :items] (fnil conj []) item)}))
Do this once, deliberately, and you'll trust the machinery afterwards: make a handler throw on purpose in dev with Xray open. The error lands inside the run that produced it. The dispatch that caused it sits right above. The category and recovery read straight off the error row. One click lands on the handler's registration site. Nothing about the failure is out-of-band — and that's the whole pitch.
Going deeper — the failure is attributed, not lumped
:rf.error/handler-exception is scoped tight: it means the event handler body
itself threw. The runtime knows the difference between that and the things
standing around it. A throw from a coeffect supplier
during context assembly (a registered reg-cofx value-returning supplier whose
body raised) emits :rf.error/coeffect-exception, attributed to the failing
cofx id rather than mis-blamed on the handler. A throw from a user
interceptor you wired into the chain emits
:rf.error/interceptor-exception, attributed to that interceptor's :id and the
:phase (:before or :after) it threw in. All three share the recovery —
:no-recovery, atomic abort, no :db install, no :fx — and all three halt the
same run. What differs is the name on the dossier, so when the trace says
:rf.error/coeffect-exception you go straight to the supplier instead of staring
at a handler that never ran. (An :after-phase interceptor throw still aborts
atomically: under the deferred-commit contract, :after
runs before the :db is installed, so a teardown throw discards the whole
effect map the same as any pre-install failure.)
A missing fx is not a missing cofx¶
You moved an fx-id behind a feature module, the load order shifted, and an event now
fires before the fx registers. The bogus entry emits :rf.error/no-such-fx, naming
the fx-id (the :rf.fx/id tag) and the event that carried it. The rest of the
handler's work proceeds: :db applies, sibling effects fire. The reason it's safe to
drop is that an effect is output — dropping one corrupts
nothing the handler already computed.
A missing coeffect is the stricter sibling, because a cofx is
input. If a handler's :rf.cofx/requires names an id with no reg-cofx
registration, the framework emits :rf.error/unregistered-cofx and
fails loud — at registration where it can check
statically, else at first processing, always before the handler runs. Running the
handler anyway would mean computing new state from a silently-missing fact, and
that's exactly the silent-nil coupling the
declared-coeffects model exists to kill.
Going deeper — the asymmetry is about data flow
Output can be partial without lying: a dropped :fx is one less side-effect, and
the state the handler computed is still true. Input cannot — a missing cofx means
a fact the handler asked for is absent, so any state computed from it is
fabricated. The runtime treats the two ends of the pipe differently on purpose:
best-effort on the output side, fail-closed on the input side. It's the same
discipline a total function applies to its domain versus its codomain.
A subscription throws, or reads one that isn't there¶
The same nil that crashed your handler can crash a
sub instead — a layer-2 sub maps over (:items cart)
while cart is still nil. The render phase has its own two categories, and they're
the exact mirror of the event side:
- A throwing sub computation emits
:rf.error/sub-exception(recovery:replaced-with-default): the sub returnsnil, the view sees no value, and the failure is named — not a blank panel with no clue. A:wheretag (:reactivefor the hot recompute path,:compute-subfor the on-demand path) tells you which resolution path threw. The fix is the same defensive default you'd apply in a handler. - A
subscribeto an unregistered sub-id — or a:<-input naming one — emits:rf.error/no-such-sub(recovery:replaced-with-default): the unresolved input is substituted withniland the sub's body still runs. This is the render-phase twin of:rf.error/no-such-handler: a botched load order degrades to anilread with the exact id named in the trace, rather than crashing the render.
For both, a missing or broken derivation yields nil and renders on, because a view
that's missing one value is still a view. That's deliberately gentler than the event
side: an :rf.error/handler-exception halts its whole run (:no-recovery), but a
sub failure is contained to the one node and its dependents, leaving the rest of the
derivation graph intact.
Test the structure, not the string¶
Errors are data, so asserting them is as boring as asserting anything else. Register
a listener on the
trace stream with rf/register-listener!
(observability is its full story), do the thing that should fail,
filter for the category, then pin the structured fields. This is the same shape the
framework's own suite uses:
;; Shape adapted from the framework's own error tests.
;; Requires [clojure.test :refer [deftest is testing]] and [re-frame.core :as rf].
(deftest unknown-fx-is-dropped-and-siblings-fire
(testing "an unknown fx-id traces :rf.error/no-such-fx and the walk continues"
(let [traces (atom [])
fired (atom [])]
;; register-listener!'s first arg is the *stream*: :trace is the
;; dev tap, :errors the always-on production channel (more below).
(rf/register-listener! :trace ::collect #(swap! traces conj %))
(try
(rf/reg-fx :checkout/analytics
(fn [_frame-ctx args] (swap! fired conj args)))
(rf/reg-event :checkout/submit
(fn [_ _]
{:fx [[:checkout/never-registered {:order-id 7}] ;; the typo under test
[:checkout/analytics {:order-id 7}]]}))
(rf/with-new-frame [_f (rf/make-frame {})]
(rf/dispatch-sync [:checkout/submit]))
(finally
(rf/unregister-listener! :trace ::collect)))
;; One bad fx does not poison the walk — the sibling still fired.
(is (= [{:order-id 7}] @fired))
;; Structural assertions: pin the contract, never the prose.
(let [errors (filter #(= :rf.error/no-such-fx (:operation %)) @traces)]
(is (= 1 (count errors)))
(let [t (first errors)]
(is (= :error (:op-type t)))
(is (= :checkout/never-registered (get-in t [:tags :rf.fx/id]))))))))
Notes — three of them, and each one generalises to every category:
- The assertions are structural. They pin
:operation,:op-type, and the schema-checked:tagskeys — the contract. They never touch:reason, the human-facing headline sentence, because its wording is allowed to change. String-shaped error tests rot; structural ones don't. - The listener is scoped to the test and detached in
finally— and detached with the same stream you registered on (:tracehere), so a failing assertion can't leak it into the next test. When you'd rather drop every listener on a stream at once, that is a fixture-layer concern —re-frame.test-support's reset clears the registries directly (there is no facade bulk-clear verb); production code never does this. - It runs on the JVM. No browser, no DOM — you register, dispatch-sync, emit, and assert, in milliseconds. Test a pipeline run covers the fixture machinery for suites of these.
The same move covers every category: dispatch-sync for event errors, a
sub computation for sub errors, frame setup and teardown
for lifecycle errors.
One verb, four streams
That first argument to register-listener! is a stream selector. :trace is the
dev tap you just used — elided out of production builds, the
firehose Xray drinks from. :errors is the always-on error
channel promised earlier: the same structured records, but it survives
production, fans across every frame, and is not filtered by any frame's egress
policy. That's why wiring Sentry in production is just
(rf/register-listener! :errors ::sentry report!). Mind one thing: nothing
arrives redacted except the event vector, so scrubbing before shipping is on you
— the how-to walks it. The other two —
:events (an always-on per-event integration hook) and :epoch
(epoch records for time-travel tooling) — are
observability's territory. One closed vocabulary; pass an
unknown stream and you get a loud :rf.error/unknown-listener-stream, no silent
default.
Advanced¶
The errors that throw, not trace¶
Everything so far has been the traced dossier — a failure inside a running
pipeline run, where the runtime records the error and recovers.
But a minority of failures don't ride the trace stream at all: they throw. A
registration is rejected, an optional feature's artefact isn't on the classpath, an
API entry point is called before init!. These surface as an
ex-info — the catalogue's "thrown ex-info" rows, mostly the registration-time
family. You meet them at the REPL, in a try/catch, or as a red boot stack trace —
not in Xray's epoch view.
They carry the same category vocabulary as the dossier, in a parallel shape, so one
consumer path reads both surfaces. The discriminator moves from :operation to
:rf.error/id, and it lives in ex-data:
(try
(rf/reg-resource :article {} request-fn) ;; oops — no :scope policy
(catch #?(:clj Exception :cljs :default) e
(:rf.error/id (ex-data e)))) ;; => :rf.error/resource-missing-scope-policy
Four slots are guaranteed on every framework throw: :rf.error/id (the
:rf.error/* category — the sole machine pivot), :where (the user-facing fn
symbol that threw, e.g. 'rf/reg-resource — unlike the traced :where, which names
a boundary or resolution path), :recovery (the same vocabulary as the traced
dossier, commonly :fix-registration here), and :reason (a one-sentence human
description). Surface-specific keys (:received, :resource-id, :cycle, …) merge
on top.
Two rules make these safe to branch on:
- Branch on
:rf.error/id, never on the message.(:rf.error/id (ex-data e))is the canonical discriminator —case/condpon it exactly as you'd match:operationon a traced record. - The message is for humans, and its wording can change.
(ex-message e)leads with an actionable sentence and trails a bracketed[:rf.error/<id>]token — e.g."… require an adapter ns and install it before boot. [:rf.error/no-adapter-installed]". That token is greppable from a raw log line, but assert against it with a substring orthrown-with-msg?regex, never whole-string equality. The same discipline as:reasonon the traced side: pin the structure, not the prose.
This is why the page can promise "one vocabulary across every surface": the traced
dossier's :operation and the thrown error's :rf.error/id are the same catalogue
keyword, so a tool — or your test — pivots on one closed set whether the failure
recovered or aborted.
Where the boundaries are¶
This page is the model — the record, the taxonomy, the recovery contract. Three adjacent concerns live elsewhere, on purpose:
- The wire itself — the trace stream these records ride, the channel split, and how Xray reads the same stream your one-line test listener just did — is observability.
- Production shipping to a monitor is a how-to.
- The server boundary — raw error records must never leak into an HTTP response — is handled by SSR, which projects them to a sanitised public shape before anything reaches the browser.