Errors¶
When a handler throws and you log it with console.error("something broke", e), you
keep the message and the stack. At the moment of the throw the runtime also knew
which event was in flight, which frame
it ran in, which handler was running, and what the
pipeline run had already done. re-frame2 keeps all of it: every
error the framework detects becomes a structured
error record with that context attached.
The error record¶
Here is the record for an event handler that threw:
{:id 42 ;; unique trace id
:op-type :error ;; severity
:operation :rf.error/handler-exception ;; category, a namespaced keyword
:recovery :no-recovery ;; what the runtime did next
:time 1781078400456 ;; emit time
:rf.trace/trigger-handler ;; the handler that was running,
{:kind :event ;; with its registration site
:id :todo/add
:source-coord {:ns app.todos :file "src/app/todos.cljs" :line 42}}
:rf.trace/call-site ;; where the `dispatch` was written
{:file "src/app/todo_views.cljs" :line 18}
:tags {:category :rf.error/handler-exception
:failing-id :todo/add
:handler-id :todo/add
:event-id :todo/add
:event [:todo/add]
:frame :app
:phase :before
:reason "Event handler threw."
:exception-message "Cannot read properties of null (reading 'trim')"}}
Three fields do most of the work:
:op-typeis severity::errorfor genuine failures,:warningfor misuse the runtime recovers from. Filter on:op-type :errorto see everything that failed.:operationis the category, such as:rf.error/handler-exceptionor:rf.error/no-such-fx. Match it exactly to narrow.:recoveryis what the framework did after the error.
:rf.trace/trigger-handler gives the file and line where the failing handler was
registered, and :rf.trace/call-site gives the line of the
dispatch that started the run. Tools render both as
jump-to-source links. Everything else is under :tags, which has one fixed,
schema-checked shape per category. :category repeats :operation so the payload
stands alone, and :failing-id names the culprit (here the handler itself, so it
equals :handler-id).
Coming from Sentry + React error boundaries?
Like a Sentry event, an error is a rich record rather than a string. Unlike Sentry, you instrument nothing: the framework emits the record itself. And unlike an error boundary, no component catches and steers. 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.
This record comes from the trace stream, which production builds elide: the code that builds it is compiled out of the release bundle. Errors that must reach a monitor in production go through a separate always-on channel with a smaller record; see Errors in production.
Xray shows every record. A development build in the browser also prints each error
that no :errors sink handled, with console.error: [re-frame2], the category and
its reason, then the record. Warnings are never printed; read them
in Xray or with a :trace listener (Observability). On the JVM
and Node nothing is printed.
Categories¶
There are dozens of categories, covering handlers, subscriptions, effects, coeffects, schemas, frames, routing, machines and SSR. You do not need to memorise them. The prefix tells you what kind of record you are holding:
:rf.error/*is a genuine failure.:rf.warning/*is recoverable misuse.:rf.fx/*,:rf.cofx/*,:rf.ssr/*and:rf.epoch/*are lifecycle records from those subsystems that use the same shape (a platform-skipped effect, a hydration mismatch).
Existing categories are never renamed or repurposed, so a test pinned to
:rf.error/no-such-fx keeps meaning the same thing. Every record also gives a
one-sentence :reason. Treat categories like HTTP status codes: look one up in the
API reference when you
meet it.
What the runtime does after an error¶
Each category has a fixed default. Four of them decide how your app degrades:
- A throwing event handler halts its run with nothing applied.
: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 does nothing, and says so.
:rf.error/no-such-handlerreports the missing registration and skips the event, so a feature module with a broken load order boots degraded instead of crashing, and the record names the missing id. - A missing fx drops only itself.
:rf.error/no-such-fxdoes not halt the run: the handler's:dbchange still applies and the other:fxentries still run. This is the one people get backwards. - A missing coeffect fails before the handler runs.
:rf.error/unregistered-cofx. The reason for the difference from a missing fx is below.
There is no app-level error policy: no global error handler, and no hook that
swallows an exception or substitutes a result. Swallowing an exception hides a bug,
and a substituted result is something the handler could not have produced. Recover
from expected failures at the source: managed HTTP's :retry for
a flaky network, or a default at the point of a read. The framework never re-runs a
failing handler; to try again, dispatch a fresh event. To observe errors, use an
:errors sink (Errors in production); v1's
reg-event-error-handler maps onto one
(From re-frame v1).
The :recovery field names the default that was applied:
:recovery |
Meaning |
|---|---|
:no-recovery |
The failing step did not complete. For a handler, coeffect or interceptor exception that is the whole run; for a missing or throwing fx, only that effect, and the rest of the run still applies. |
:replaced-with-default |
An unresolved input was substituted (for example nil for a missing sub input) and the body ran on. |
:logged-and-skipped |
The offending input was dropped; its siblings still applied. |
:warned-and-replaced |
Two writes conflicted over one target and the last one won. Advisory only. |
:skipped |
A platform-gated effect did not run. Not really an error. |
:fix-registration |
A reg-* call was malformed: a typo, a bad argument shape, a view missing its args vector. |
A few categories have their own value, such as :supply-frame on the missing-frame
error below.
One category, three modes
:rf.error/no-such-handler covers three registrar misses,
distinguished by a :kind tag: :kind :event (the dispatch case above),
:kind :route (a URL that matched no registered
route; see routing), and
:kind :frame (a tool addressing a frame id that is not registered). Filter on the
category for every registrar miss; branch on :kind for per-mode handling.
Common failures¶
These are the four you will meet first. The rest read the same way.
A dispatch with no frame in scope¶
You wrote (rf/dispatch [:todo/toggle 1]) at the REPL, in a setTimeout callback,
in a promise .then, or in a button's :on-click, and got an error instead of a
run. Nearly everyone hits this once.
dispatch and
subscribe find their frame in the surrounding
scope, and a deferred callback runs on a fresh stack 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 throws, so
nothing is dispatched.
The fix is to carry the frame across the async gap:
- In an effect handler, pass the context argument's
:frametocapture-framebefore registering the callback, and reply through thedispatchit returns, as Effects shows. - Elsewhere in app code, use
capture-framethe same way; Frames covers it. - At the REPL or in a test,
with-frameandwith-new-frameestablish the scope.
Views are where this is met most often. A view renders inside the
frame-provider, but its :on-click runs later, after
the render has finished, so #(rf/dispatch [:todo/toggle id]) inside one throws. Use
the dispatch and subscribe that reg-view injects: they capture the
frame at render time.
Views has the
wrong and right versions side by side.
A handler throws¶
Adding a todo trims the title the user typed, which the input keeps in app-db under
:new-title. Before anyone has typed there is no :new-title, and str/trim on
nil throws. Click Add a todo:
(require '[clojure.string :as str]
'[re-frame.core :as rf])
(rf/reg-event :todo/add
(fn [{:keys [db]} _]
(let [id (inc (apply max 0 (keys (:todos db))))
title (str/trim (:new-title db))] ;; throws while :new-title is nil
{:db (assoc-in db [:todos id] {:id id :title title :done? false})})))
(rf/reg-sub :todo/count (fn [db _] (count (:todos db))))
(rf/reg-view add-todo []
[:div
[:button {:on-click #(dispatch [:todo/add])} "Add a todo"]
[:p @(subscribe [:todo/count]) " todos"]])
[rf/frame-root {:id :app}
[add-todo]]
The count stays at 0. The runtime catches the exception and emits
:rf.error/handler-exception with :recovery :no-recovery: the run halts, nothing
is committed, and app-db is untouched. Fix it with a default at the point of access:
(rf/reg-event :todo/add
(fn [{:keys [db]} _]
(let [id (inc (apply max 0 (keys (:todos db))))
title (str/trim (:new-title db ""))]
{:db (assoc-in db [:todos id] {:id id :title title :done? false})})))
Make the same change in the cell, press Ctrl-Enter (Cmd-Enter on macOS),
and click again: the count goes up.
Make a handler throw on purpose in dev with Xray open. The error appears inside the run that produced it, below the dispatch that caused it, with its category and recovery on the row and a link to the handler's registration site.
Coeffect and interceptor exceptions
:rf.error/handler-exception means the event handler body threw. A throw from a
coeffect supplier while the handler's inputs are assembled
emits :rf.error/coeffect-exception, attributed to the cofx id. A throw from an
interceptor emits :rf.error/interceptor-exception,
attributed to the interceptor's :id and the :phase (:before or :after) it
threw in. All three have recovery :no-recovery (no :db, no :fx) and halt the
run; the different name sends you to the supplier or interceptor instead of a
handler that never ran. An :after throw also discards the handler's result,
because :after runs before the :db is committed.
A missing fx is not a missing cofx¶
You moved an fx into a feature module, the load order shifted, and an event now runs
before the fx is registered. The unknown entry emits :rf.error/no-such-fx, naming
the fx id (the :rf.fx/id tag) and the event that returned it. The rest of the
handler's work proceeds: :db applies and the other effects run. Dropping it is safe
because an effect is output; losing one does not invalidate
anything the handler computed.
A missing coeffect is stricter, 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 halts the run: at registration
where it can check statically, otherwise at first use, and always before the handler
runs. Running the
handler anyway would compute new state from a missing fact, which is what
declared coeffects exist to prevent.
A subscription throws, or reads one that isn't there¶
The same missing data can break a subscription instead:
:todo/remaining-count calls a function on a todo that is still nil. The render
phase has two categories, mirroring the event side:
- A throwing sub computation emits
:rf.error/sub-exception(recovery:replaced-with-default). The sub returnsniland the failure is named. Fix it with a default, as in a handler. - A
subscribeto an unregistered sub id, or an:inputsentry naming one, emits:rf.error/no-such-sub(recovery:replaced-with-default). The missing input isniland the sub's body still runs. This is the render-side counterpart of:rf.error/no-such-handler.
In both cases the view still renders, with one value missing. That is gentler than the event side: a handler exception halts the whole run, while a sub failure is contained to that node and its dependents in the derivation graph.
Errors in production¶
The record above comes from the :trace stream, which production builds
elide. A separate always-on channel survives. Declare
{:observability {:errors [{:sink :app/sentry}]}} on the frame, or once for every
frame with (rf/configure! {:observability {:errors [{:sink :app/sentry}]}}), and
register the function with rf/register-observability-sink!. The record your sink
receives is smaller than the trace record. Its category is under :error rather than
:operation, and it arrives already projected under the frame's
data classification, so sensitive paths are
redacted before your code sees them. The process-wide entry also receives records
whose frame does not resolve at all.
Report errors in production walks through
it. On the server, SSR projects error records to a sanitised
public shape before anything reaches the browser.
Test the structure, not the string¶
Errors are data, so you assert on them like any other data. Register a
listener on the trace stream with
rf/register-listener! (Observability covers listeners), do the
thing that should fail, filter for the category, and check the structured fields:
;; test/my_app/errors_test.clj
(ns my-app.errors-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[re-frame.core :as rf]
[re-frame.substrate.plain-atom :as plain-atom] ;; the headless JVM adapter
[re-frame.test-support :as ts]))
;; Installs the adapter every frame needs, and resets the runtime around each test.
(use-fixtures :each (ts/make-reset-runtime-fixture {:adapter plain-atom/adapter}))
(deftest unknown-fx-is-dropped-and-siblings-fire
(testing "an unknown fx id traces :rf.error/no-such-fx and the other fx still run"
(let [traces (atom [])
saved (atom [])]
;; The first argument is the stream; :trace is the dev trace stream.
(rf/register-listener! :trace ::collect #(swap! traces conj %))
(try
(rf/reg-fx :todo.storage/save
(fn [_ctx todos] (swap! saved conj todos)))
(rf/reg-event :todo/clear-done
(fn [_ _]
{:fx [[:todo.toast/show "Cleared"] ;; never registered: the fault under test
[:todo.storage/save {}]]}))
(rf/with-new-frame [_f (rf/make-frame {})]
(rf/dispatch-sync [:todo/clear-done]))
(finally
(rf/unregister-listener! :trace ::collect)))
;; The other fx still ran.
(is (= [{}] @saved))
;; Assert on the structure, 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 (= :todo.toast/show (get-in t [:tags :rf.fx/id]))))))))
Notes:
- The assertions are structural. They check
:operation,:op-typeand:tagskeys, never:reason, whose wording can change. - The listener is scoped to the test and removed in
finally, on the stream it was registered on, so a failing assertion cannot leak it into the next test. Clearing every listener at once is the reset fixture's job, and there is no public bulk-clear function. - It runs on the JVM. No browser and no DOM: register, dispatch-sync, and assert in milliseconds. Test a pipeline run covers fixtures for suites of these.
The same approach covers every category: dispatch-sync for event errors, a
subscription computation for sub errors, frame setup and teardown for lifecycle
errors.
Advanced¶
The errors that throw, not trace¶
Everything so far is a traced record: a failure inside a
pipeline run, where the runtime records the error and recovers.
Some failures throw instead: a registration is rejected, an optional feature's
artefact is not on the classpath, or an API is called before
init!. These surface as an ex-info, mostly at registration
time. You meet them at the REPL, in a try/catch, or as a stack trace at boot,
not in Xray's epoch view.
They use the same category vocabulary, under :rf.error/id in ex-data:
(try
(rf/reg-resource :todo/list {} fetch-todos) ;; no :scope policy
(catch #?(:clj Exception :cljs :default) e
(:rf.error/id (ex-data e)))) ;; => :rf.error/resource-missing-scope-policy
Every framework throw has four slots:
:rf.error/id, the category.:where, the public function that threw, such as'rf/reg-resource. (On a traced record,:wherenames a code path instead.):recovery, from the same vocabulary as traced records, usually:fix-registration.:reason, a one-sentence description.
Surface-specific keys (:received, :resource-id, :cycle, …) are merged on top.
Branch on :rf.error/id with case or condp, never on the message. The message is
for humans: (ex-message e) starts with an actionable sentence and ends with a
bracketed [:rf.error/<id>] token, e.g.
"… require an adapter ns and install it before boot. [:rf.error/no-adapter-installed]".
The token is greppable in a raw log. In tests, match it with a substring or a
thrown-with-msg? regex, never whole-string equality.
Because a traced :operation, a sink record's :error and a thrown :rf.error/id
come from the same catalogue, a tool or test branches on one set of keywords whether
the failure recovered or aborted.
Schema validation failures¶
If you guard an app-db path or an event with a
schema, a value that fails it emits
:rf.error/schema-validation-failure. The recovery depends on the boundary named in
the :where tag:
:where |
Recovery |
|---|---|
:event |
The handler is skipped. |
:app-db, :machine-data |
The run is rolled back; nothing commits. |
:fx-args |
Only the offending fx is skipped; the others run. |
:sub-return, :sub-override |
The sub yields nil and the view renders on. |
:flow-output, :machine-output |
Observational only; the value still commits. |
:explain holds the Malli explanation. A schema that is itself malformed (a
childless [:vector], an unknown operator) is a separate category,
:rf.error/malformed-schema. Malli only finds it the first time the schema runs, and
from then on every commit is rolled back until you fix the registration.
These checks run only in dev. A production build elides the validators you
declared over your own code, so treat a reg-app-schema guard as a development
assertion. In a release build a value that violates it installs silently.
In dev, a rollback also reaches the :errors stream: one record per failing
registration (:app-db and :machine-data rejections, and
:rf.error/malformed-schema), with :rollback? true, the :registered-path (or the
:machine-id and :phase) and a :reason. A failure that only skips one fx or
replaces a sub with nil stays on the trace, because nothing was discarded.
While no sink handles these records they are printed to the console. An empty page
with red [re-frame2] :rf.error/schema-validation-failure … got nil lines usually
means a schema registered as non-nilable over a slot nothing has written yet: every
transaction is rejected, so nothing ever installs. Register the path as nilable, or
seed it before the first write. Wiring an :errors sink (on the frame or through
rf/configure!) handles the records and the console lines stop.
A sink reading these records must look under :tags: use
(get-in record [:tags :rollback?]), not (:rollback? record). On this record only
:error, :event-id, :frame, :time and :kind are top-level; :rollback?,
:where, :registered-path, :reason and :recovery are under :tags.
A few schema checks run in every build. The one in this category is an event handler
registered with {:schema … :boundary? true}: a payload that fails is rejected in
production too, and reported as a :rf.error/schema-validation-failure record tagged
:source :boundary on the :errors sink, carrying identifiers only. A managed-HTTP
:decode schema reports :rf.http/decode-failure instead, and a recordable
coeffect's :schema throws :rf.error/cofx-value-invalid.
Validate with schemas
lists them all, with the production record's keys.