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 own defresolver 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")))

Caveats

Here I expose the errors more less as-is. You might prefer to show a generic error message, perhaps with a unique ID that can be correlated with logs, instead of exposing the low-level technical details to your users.


Tags: webdev Fulcro ClojureScript


Copyright © 2024 Jakub Holý
Powered by Cryogen
Theme by KingMob