Error handling in Fulcro: 3 approaches
I present three ways of detecting, handling, and showing server-side errors: globally and at the component level.
By default, Fulcro considers only non-200 HTTP status as an error. It is up to you to tell it what is an error and how to handle it.
This is somewhat controversial - as Programming with Pure Optimism in the Fulcro Developers Guide explains:
A server should not throw an exception and trigger a need for error handling unless there is a real, non-recoverable situation.
And, as Tony explained elsewhere (paraphrasing):
Make sure resolvers never throw, and have them return errors as first-class data. Only (detectable) security hacks and (unexpected) bugs should be hard-core errors. Intentional behavior of your server should always return a sensical value for a query, which may in fact simply be something like: “form save failed”. In that case components can query for problems with a real query prop, and each resolver can populate that key with an error if it has one. So, if you want to do component-level error handling, just adopt that philosophy and make
remote-error?
assume that something serious went wrong and the user probably should call support, reload the page, and perhaps even log back in. (You can for example define your owndefresolver
macro that automatically adds error handling.)
In my case, I have an internal application and I encounter mostly bugs and downstream service issues so this approach is a better fit for me than if I had a public-facing application.
Approach 1: Global error detection and display
Configure Pathom
Make sure to include the p/error-handler-plugin
in your Pathom parser. Thus the result of any call might include the map ::p/errors
with a path and error details.
Detect errors
Even if pathom returns any ::p/errors
, it will not be visible to your code unless you ask for it. You can leverage the :global-eql-transform
to ask for it in every query - see how RAD does it.
Set the :remote-error?
on your app to a function that returns true
if there are errors:
(defonce app
(app/fulcro-app
{:remote-error?
(fn remote-error? [{:keys [body] :as result}]
(or
(app/default-remote-error? result) ; if status <> 200
(map? (:com.wsscode.pathom.core/errors body))))
;; ...
}))
When :remote-error?
returns true, Fulcro’s :global-error-action
will be called. We can use it to store the error into the state:
(defn global-error-action
"Run when app's :remote-error? returns true"
[{:keys [app state], {:keys [body error-text]} :result :as env}]
(let [pathom-errs (:com.wsscode.pathom.core/errors body)
msg (cond
(seq error-text)
error-text
pathom-errs
(->> pathom-errs
(map (fn [[query {{:keys [message data]} :com.fulcrologic.rad.pathom/errors :as val}]]
(str query
" failed with "
(or (and message (str message (when (seq data) (str ", extra data: " data))))
val))))
(str/join " | "))
:else
(str body))]
;; Store the error into the state for display:
(swap! state assoc :ui/global-error msg)))
;; ...
(defonce app
(app/fulcro-app
{:remote-error? ...
:global-error-action global-error-action
;; ...
}))
Display an error
Let’s add an error display component right under the root. I haven’t found a simple way to hide it, e.g. on the next successful load/mutation, so it has to be dismissed manually by the user.
(ns example.ui
(:require
#?@(:cljs [["semantic-ui-react" :refer [Message]]])
;;...
))
(def ui-message #?(:cljs (interop/react-factory Message)))
(defsc GlobalErrorDisplay [this {:ui/keys [global-error] :as props}]
{:query [[:ui/global-error '_]]
:ident (fn [] [:component/id :GlobalErrorDisplay])
:initial-state {}}
(when global-error
(ui-message
{:content (str "Noe gikk galt: " global-error)
:error true
;; Trigger a mutations that removes :ui/global-error
:onDismiss #(comp/transact!! this [(mutations/reset-global-error)])})))
(def ui-global-error-display (comp/factory GlobalErrorDisplay))
(defsc Root [_ {::app/keys [active-remotes] :root/keys [root-router global-error]}]
{:query [{:root/global-error (comp/get-query GlobalErrorDisplay)} #_...]
:initial-state {:root/global-error {}}}
(dom/div
(div :.ui.container.segment
(ui-global-error-display global-error)
(dom/p "Body of the application..."))))
Approach 2: Component-level error handling (not tested)
Here we want to display an error in place of the component querying for the data that failed, so that the error is displayed close to the place where it matters.
Configure Pathom to expose errors next to the failed property
Instead of having a single, top-level ::p/errors
, we want this to be included next to the property that failed. So if we query somewhere for :person/credit
and it fails, we want to get back {… :person/credit :p/reader-error, ::p/errors {…}}
. Components can thus query and access this.
We need to add the p/raise-errors
to our Pathom parser configuration _after the p/error-handler-plugin
:
(def parser
(p/parser
{#_...
::p/plugins [#_...
p/error-handler-plugin
(p/post-process-parser-plugin p/raise-errors)]}))
Beware that if you have any of these plugins (p/post-process-parser-plugin p/elide-not-found)
,
(p/post-process-parser-plugin elide-reader-errors)
, they must run only after raise-errors
.
Drop/modify the error detection
We don’t have the top-level ::p/errors
anymore. Each component queries and displays its errors so we don’t need the global-error-action
. If you want it anyway, for example to still display a top-level warning that there were any error, you need to modify :remote-error?
, for example using something like this (not tested):
(->> body (tree-seq coll? #(cond-> % (map? %) vals)) (some ::p/errors))
Make each component query for its errors
Even though the ::p/errors
data is in the response, no component will be able
to see it unless it includes it in its query. It doesn’t need to be sent to the
server; actually you want to exclude it from sending because it would just turn
into ::pc/not-found
, which would break raise-errors
(unless preceded by the
(p/post-process-parser-plugin p/elide-not-found)
plugin). Use the
:global-eql-transform
to omit them.
The only option I see here is to create and use a customized version of Fulcro’s
defsc
(and perhaps RAD’s defsc-report
, defsc-form
), that adds ::p/errors
to the query.
Wrap components to display the error instead, if present
We can use the Fulcro app’s :render-middleware
to wrap each component with
error handling:
;; In :require - ["semantic-ui-react" :refer [Message]]
(def ui-message #?(:cljs (interop/react-factory Message)))
(defonce app
(app/fulcro-app
{:render-middleware
(fn [this render]
(if-let [errs (::p/errors (comp/props this))]
(ui-message {:content (str (comp/component-name this)
" failed to render due to: " errs)
:error true})
(render this)))
;; ...
}))
However, since we already have to create our own defsc
variant, it is better
to do this inside its body (:render-middleware
applies to all components, even
those provided by Fulcro’s libraries.)
Approach 3: Global error detection and display with support for component-specific handling
This is a combination of #1 and #2 - a global detection and display of errors but allowing a component to query for
::p/errors
and handle the error(s) itself instead.
Configure Pathom to expose errors next to the failed property and at the top level
This is the same as approach 2 but instead of p/raise-errors
we will use a slightly modified version of the function, which skips the dissoc
, i.e. (dissoc data :com.wsscode.pathom.core/errors) → data
. (When you copy the function into your code, remember to change ::reader-error
in the code to ::p/reader-error
😅.)
Detect and display errors skipping those handled by the target component
This is similar to how we defined :remote-error?
in #1 but we remove those handled.
(defn target-component-requests-errors [query path]
(some->> (when (vector? path) (butlast path)) ; path can be a single keyword -> ignore
(get-in query)
meta
:component
(comp/get-query)
(some #{:com.wsscode.pathom.core/errors})))
(defn extract-query-from-transaction
"Extract the component query from a `result`.
Ex. tx.: `[({:all-organizations [:orgnr ...]} params) ::p/errors]`,
`[{:people [:orgnr ...]} ::p/errors]`"
[original-transaction]
(let [query (first original-transaction)]
(cond-> query
;; A parametrized query is wrapped in (..) but we need the raw data query itself
(list? query) (first))))
(defn unhandled-errors
"Returns Pathom errors (if any) that are not handled by the target component"
[result]
;; TODO Handle RAD reports - their query is `{:some/global-resolver ..}` and it lacks any metadata
(let [query (extract-query-from-transaction (:original-transaction result))
load-errs (:com.wsscode.pathom.core/errors (:body result))
mutation-sym (as-> (-> query keys first) x
(when (sequential? x) (first x))
(when (symbol? x) x))
mutation-errs (when mutation-sym
(get-in result [:body mutation-sym :com.fulcrologic.rad.pathom/errors]))]
(cond
load-errs
(reduce
(fn [unhandled-errs [path :as entry]]
(if (target-component-requests-errors query path)
(do
(log/info "unhandled-errors: Ignoring error for" (last path) ", handled by the requesting component")
unhandled-errs)
(conj unhandled-errs entry)))
{}
;; errors is a map of `path` to error details
(:com.wsscode.pathom.core/errors (:body result)))
:else
mutation-errs)))
(defn global-error-action2 [{:keys [component state result]:as env}]
;; Ignore mutation errors if the triggering component handles them itself
;; these are propagated to the component in m/update-errors-on-ui-component!
(when-not (some-> component comp/get-query set ::m/mutation-error)
(global-error-action env))) ; defined above in #1
(defonce app
(app/fulcro-app
{:global-error-action global-error-action2
:remote-error?
(fn remote-error? [result]
(or
(app/default-remote-error? result) ; if status <> 200
(seq (unhandled-errors result))))
;; ...
}))
When desired, handle errors locally in a component
(defs Example [_ {:keys [::p/errors ::m/mutation-error]}]
{:query [::p/errors ::m/mutation-error #_...]}
(cond
mutation-error (dom/p "A mutation triggered here failed! Details:"
(get-in mutation-error
[:body `api/send-msg ::rad.pathom/errors :message]))
errors (dom/p "A load failed!")
:else (dom/button {:onClick
#(comp/transact! this [(api/send-msg {})])}
"Send msg")))