org.clojars.nomicflux/danger-mouse

0.4.1-SNAPSHOT


Transducer friendly error-handling in Clojure

dependencies

org.clojure/clojure
1.10.1
prismatic/schema
1.4.1
org.clojure/core.async
1.6.681
criterium
0.4.6



(this space intentionally left almost blank)
 

Transducer Error Handling

(ns danger-mouse.catch-errors)
(defn process-error
  [error input]
  {:error-msg (ex-message error)
   :error error
   :input input})

Transducer to catch errors, capture additional info, and cache them as DM errors in a side channel that will be returned at the end of the reduction.

(def catch-errors
  (fn [rf]
    (let [errors (volatile! [])]
      (fn
        ([] (try (rf)
                 (catch Exception e
                   {:result nil
                    :errors (conj @errors (process-error e nil))})))
        ([result] (try {:result (rf result)
                        :errors @errors}
                       (catch Exception e
                         {:result result
                          :errors (conj @errors (process-error e result))})))
        ([result input] (try (rf result input)
                             (catch Exception e
                               (vswap! errors conj (process-error e input))
                               result)))))))
(defn errors-coll?
  [coll]
  (and (map? coll) (= (set (keys coll)) #{:result :errors})))

Helper function to separate out results from errors after applying a transducer. Collection is first.

(defn transduce->
  [coll xform initial & args]
  (let [start (if (errors-coll? coll) (:result coll) coll)
        {new-result :result
         new-errors :errors}
        (transduce (apply comp catch-errors args) xform initial start)]
    {:result new-result
     :errors (into (or (:errors coll) []) new-errors)}))

Helper function to separate out results from errors after applying a transducer. Collection is last.

(defn transduce->>
  [xform initial & args-and-coll]
  (let [coll (last args-and-coll)
        args (drop-last args-and-coll)]
    (apply transduce-> coll xform initial args)))

Helper function to separate out results from errors in a collection. Collection is first.

(defn catch-errors->
  [coll & args]
  (apply transduce-> coll conj [] args))

Helper function to separate out results from errors in a collection. Collection is last.

(defn catch-errors->>
  [& args-and-coll]
  (let [coll (last args-and-coll)
        args (drop-last args-and-coll)]
    (apply catch-errors-> coll args)))
 

Error Handling Transducers

(ns danger-mouse.transducers
  (:require [danger-mouse.utils :as utils]
            [clojure.string :as str]
            [schema.core :as s]
            [danger-mouse.schema :as dm-schema]
            [clojure.string :as str]))

Transducers

Like danger-mouse.catch-errors#catch-errors, but instead of collecting errors as it goes, presents them in danger-mouse error format for later handling. If further transducers are used, chain should be used to compose them.

(def contain-errors-xf
  (fn [rf]
    (fn
      ([] (rf))
      ([result] (try (rf result)
                     (catch Exception e
                       (dm-schema/as-error {:error-msg (.getMessage e)
                                            :error e
                                            :input result}))))
      ([result input] (try (rf result input)
                           (catch Exception e
                             (rf result
                                 (dm-schema/as-error {:error-msg (.getMessage e)
                                                      :error e
                                                      :input input}))))))))

Handle errors as part of the transduction process via handler, removing them from later stages.

(defn handle-errors-xf
  [handler]
  (fn [rf]
    (fn
      ([] (rf))
      ([result] (rf result))
      ([result input]
       (utils/resolve
        (fn [error] (handler error) result)
        (fn [success] (rf result success))
        input)))))

Tranducer transformer that takes an existing transducer xf, and applies it to unmarked values while using handler to deal with and remove errored values.

(defn handle-and-continue-xf
  [handler xf]
  (fn [rf]
    (fn
      ([] (rf))
      ([result] (rf result))
      ([result input]
       (utils/resolve
        (fn [error] (handler error) result)
        (fn [success] ((xf rf) result success))
        input)))))

Propogates errors as errors, and otherwise applies the marked transducer xf.

(defn carry-errors-xf
  [xf]
  (fn [rf]
    (let [transformed (xf rf)]
      (fn
       ([]
        (transformed))
       ([result]
        (transformed result))
       ([result input]
        (utils/resolve
         (fn [error]
           (rf result (dm-schema/as-error error)))
         (fn [success]
           (transformed result success))
         input))))))

Transducer Helper Functions

Takes a splat of transducers xfs and wraps them to carry errors forward and otherwise act on values as normal. As no grouping is necessary, this can be used on arbitrary collections, including streams and infinite lists.

(defn chain
  [& xfs]
  (apply comp (map carry-errors-xf xfs)))

Takes a splat of transducers xfs. Any errors encountered will be thrown into a side channel errors and returned as part of a GroupedResults. Blocks until transduction is complete, so not appropriate for streaming.

(defn collect
  [& xfs]
  (fn [coll]
    (let [errors (transient [])
          handler (handle-errors-xf (fn [e]
                                      (conj! errors e)))
          result (into []
                       (apply comp (interleave (cons handler xfs) (repeat handler)))
                       coll)]
      {:errors (persistent! errors)
       :result result})))
 

Schemas & Functions for them

(ns danger-mouse.schema
  (:require [schema.core :as s]))

Schemas

(s/defschema ErrorResult
  {::error s/Any})
(s/defschema GroupedResults
  {:errors [s/Any]
   :result [s/Any]})
(s/defschema ProcessedError
  {:error-msg s/Str
   :error Throwable
   :input s/Any})

Used to show that a result of schema can now be accompanied with ProcessedErrors.

(defn WithErrors
  [schema]
  {:errors [ProcessedError]
   :result schema})

Schema Utility Functions

Format any value as an error. Preferable to using the keyword manually, but the tools in utils are preferred over explicitly creating errors.

(s/defn as-error :- ErrorResult
  [x :- s/Any]
  {::error x})

Check whether a value is an error. Preferable to checking the keyword manually.

(s/defn is-error? :- s/Bool
  [{::keys [error]}]
  (not (not error)))

Retrieve error value, which can be an Any (not necessarily an exception). Preferable to destructuring manually.

(s/defn get-error
  [{::keys [error]}]
  error)
 

Utility Functions

(ns danger-mouse.utils
  (:require [danger-mouse.schema :as dm-schema]
            [schema.core :as s]))

Collection Helper

(s/defn collect-results-map :- dm-schema/GroupedResults
  "Separate out errors and result from a vector argument and return them
   in a map."
  [xs :- [s/Any]]
  (loop [[y & ys :as all] xs
         errors (transient [])
         result (transient [])]
    (cond
      (empty? all) {:errors (persistent! errors)
                    :result (persistent! result)}
      (dm-schema/is-error? y) (recur ys (conj! errors (dm-schema/get-error y)) result)
      :else (recur ys errors (conj! result y)))))

Mapping functions

Only apply success-fn to a successful (i.e. unmarked) result.

(defn on-success
  [success-fn
   result]
  (if (dm-schema/is-error? result)
    result
    (success-fn result)))

Only apply error-fn to an error result (one marked with the appropriate keyword).

(defn on-error
  [error-fn
   result]
  (if (dm-schema/is-error? result)
    (dm-schema/as-error (error-fn (dm-schema/get-error result)))
    result))

Apply error-fn if the result is an error, and otherwise apply success-fn. Unlike resolve, this leaves an error as an error.

(defn on-error-and-success
  [error-fn
   success-fn
   result]
  (if (dm-schema/is-error? result)
    (dm-schema/as-error (error-fn (dm-schema/get-error result)))
    (success-fn result)))

Apply error-fn to the error value if result is an error, otherwise apply success-fn. Unlike on-error-and-success, this will allow the user to transform an error into a success.

(defn resolve
  [error-fn
   success-fn
   result]
  (if (dm-schema/is-error? result)
    (error-fn (dm-schema/get-error result))
    (success-fn result)))

Error handling functions

(s/defn handle-errors :- [s/Any]
  "After using `collect-results-map` to group values, this function will handle the `errors`
   portion using `handler` (which only produces side effects) and returns only the `result`."
  [handler :- (s/=> (s/named (s/eq nil) 'Unit) [s/Any])
   {:keys [errors result]} :- dm-schema/GroupedResults]
  (handler errors)
  result)

Function version of a try-catch block. The body must be provided as a thunk to delay processing. Macro version in danger-mouse.macros#try-catch.

(s/defn try-catch*
  [thunk :- (s/=> s/Any)]
  (try
    (thunk)
    (catch Exception e
      {::dm-schema/error e})))
 
(ns danger-mouse.threading)

Update the errors of a GroupedResult or a WithErrors. Takes the coll in the last position, and a body that threads errors in the last position.

(defmacro update-errors->>
  [& body-and-coll]
  (let [coll (last body-and-coll)
        body (drop-last body-and-coll)]
    `(update ~coll :errors #(->> % ~@body))))

Update the result of a GroupedResult or a WithErrors. Takes the coll in the last position, and a body that threads result in the last position.

(defmacro update-result->>
  [& body-and-coll]
  (let [coll (last body-and-coll)
        body (drop-last body-and-coll)]
    `(update ~coll :result #(->> % ~@body))))

TODO: This has the result in the first position, but still assumes that the functions passed in to update need the argument in last position. This has been my most common use case so far, but at least the naming needs to be cleared up.

Update the errors of a GroupedResult or a WithErrors. Takes the coll in the first position, and a body that threads errors in the last position.

(defmacro update-errors->
  [coll & body]
  `(update ~coll :errors #(->> % ~@body)))

Update the result of a GroupedResult or a WithErrors. Takes the coll in the first position, and a body that threads result in the last position.

(defmacro update-result->
  [coll & body]
  `(update ~coll :result #(->> % ~@body)))
 

Macros

(ns danger-mouse.macros
  (:require [danger-mouse.utils :as utils]))

A try-catch block in function form. Uses a macro to delay resolution of the body.

(defmacro try-catch
  [& body]
  `(utils/try-catch* (fn [] ~@body)))
 
(ns danger-mouse.async
  (:require [clojure.core.async :as async]
            [danger-mouse.transducers :as dm-transducers]))
(defn safe-channel
  [buf-or-n & xforms]
  (async/chan buf-or-n (apply dm-transducers/chain
                              dm-transducers/contain-errors-xf
                              xforms)))