Last active
August 4, 2024 12:45
-
-
Save vvvvalvalval/f1250cec76d3719a8343 to your computer and use it in GitHub Desktop.
Asynchronous error management in Clojure(Script)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;; 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
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.