Asynchronous error management in Clojure(Script)
;; Synchronous Clojure trained us to use Exceptions, while asynchronous JavaScript has trained us to use Promises. | |
;; In contexts where we work asynchronously in Clojure (in particular ClojureScript), it can be difficult to see a definite way of managing failure. Here are some proposals. | |
;; OPTION 1: adapting exception handling to core.async CSPs | |
;; As proposed by David Nolen, with some macro sugar we use Exceptions in go blocks with core async in the same way we would do with synchronous code. | |
(require '[clojure.core.async :as a :refer [go]]) | |
;; defining some helper macros | |
(defn throw-err "Throw if is error, will be different in ClojureScript" | |
[v] | |
(if (isa? java.lang.Throwable v) (throw v) v)) | |
(defmacro <? "Version of <! that throw Exceptions that come out of a channel." | |
[c] | |
`(throw-err (a/<! ~c))) | |
(defmacro err-or "If body throws an exception, catch it and return it" | |
[& body] | |
`(try | |
~@body | |
(catch [java.lang.Throwable e#] e#))) | |
(defmacro go-safe [& body] | |
`(go (err-or ~@body))) | |
;; examples | |
(go | |
(try | |
(let [v1 (a/<? (dangerous-op-1)) | |
v2 (a/<? (dangerous-op-2 v1)) | |
v3 (a/<? (dangerous-op-3 v1 v2))] | |
(make-something-of v1 v2 v3)) | |
(catch [Throwable e] (println "something wrong happened")))) | |
;; OPTION 2: using monads | |
;; Option 1 lets you deal with failure the same ways synchronous code does - with the Error type of the host platform. | |
;; Another approach, more akin to JavaScript Promises, is to use Monads. | |
;; The Cats library is one of several options for using monads in Clojure and ClojureScript. | |
;; What interests us here is the Exception monad type. | |
(require '[cats.core :as cats :refer [mlet]]) | |
(require '[cats.monad.exception :as exc]) | |
;; An Exception monadic value can be either a Success of some data, or a Failure of some error. | |
(def mv1 (exc/success 42)) | |
mv1 ; => #<Success@3efd25fd: 42> | |
(def mv2 (exc/failure {:reason "no can do."})) | |
(type mv2) ; => cats.monad.exception.Failure | |
;; Monads are most interesting used with the mlet macro, which lets you write code in a world withour errors: | |
(mlet [v1 (exc/success 42) | |
v2 (exc/failure {:reason "no can do"}) | |
v3 (exc/success 23) | |
v4 (+ v1 v3)] | |
(+ v1 v4)) | |
;; The thing with monads is... they're not very compatible with core.async's go blocks. | |
;; Monads are about using functions to transform values, hence the mlet macro expands to a lot of nested functions. | |
;; But since the inversion of control provided by go blocks stops at function boundary, you typically can't mix mlet and <! or >!. | |
;; So assuming that the `dangerous-op-*` yield Exception Monad values, the following will not compile: | |
(declare dangerous-op-1 dangerous-op-2 dangerous-op-3) | |
(go | |
(mlet [v1 (a/<! (dangerous-op-1)) | |
v2 (a/<! (dangerous-op-2 v1)) | |
v3 (a/<! (dangerous-op-3 v1 v2))])) | |
; => IllegalArgumentException No method in multimethod '-item-to-ssa' for dispatch value: :fn clojure.lang.MultiFn.getFn (MultiFn.java:160) | |
;; However, making the Exception Monad work nicely with `go` is not a lost cause. | |
;; We can write our own let-like macro that expands to matching on the type of the monadic values, on which the `go` macro can operate its magic. | |
;; Below is a simplistic implementation: | |
(defmacro exc-let "Like let, but assumes that all the right hand expressions in the binding forms will evaluate to an Exception monadic value. | |
The left-expression in the bindings forms will be bound to the wrapped value if successful, otherwise the failure will be returned instead. | |
You may want to use it instead of cats.core/mlet inside of clojure.core.async/go blocks." | |
[bindings & body] | |
(->> bindings (partition 2) reverse | |
(reduce | |
(fn [inner [l r]] | |
`(let [l# ~r] | |
(cond | |
(exc/failure? l#) l# | |
(exc/success? l#) (let [~l (exc/extract l#)] ~inner) | |
))) | |
`(do ~@body) | |
))) | |
;; Example: | |
(go | |
(exc-let [v1 (a/<! (a/go (exc/success 42))) | |
v2 (a/<! (a/go (exc/failure {:reason "no can do."}))) | |
v3 (a/<! (a/go (exc/try-on (+ v1 23))))] | |
(+ v1 v2 v3))) | |
;; OPTION 2-bis: using maps | |
;; Instead of a monadic type, you can use plain clojure maps with the following schema | |
{:outcome :success :data 42} ; successful value | |
{:outcome :error :data "no can do."} ; failed value | |
;; Depending on your situation this may be more portable; it also has the advantage that you can use core.match on it out of the box | |
(require '[clojure.core.match :refer [match]]) | |
(defn log-outcome! [v] | |
(match [v] | |
[{:outcome :success :data data}] (prn "I succeeded!" data) | |
[{:outcome :error :data err}] (prn "I failed..." err))) | |
(log-outcome! {:outcome :success :data 42}) | |
(log-outcome! {:outcome :error :data "no can do."}) | |
;; OPTION 3: using Promises | |
;; If you're in ClojureScript, and not interested in core.async, you can just use a Promise library: | |
;; - funcool/promesa is a ClojureScript wrapper of the popular Bluebird JavaScript library. | |
;; - jamesmacaulay/cljs-promises is a Promise library designed to operate nicely with core.async. | |
;; Promises take care of both asynchrony and error management (they're essentially a mix of Futures and Exception Monads); some may say it's convenient, others may argue it's not simple. | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
Are you aware of https://github.com/alexanderkiel/async-error ? That is nice, lightweight and explicit. From the README example:
This function returns a channel conveying either a vector of a and b or one of the errors conveyed by ch-a or ch-b. It will never read from ch-b if ch-a returns an error.