Skip to content

Instantly share code, notes, and snippets.

@metametadata
Created June 24, 2015 13:04
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save metametadata/9f1c205b8092d7b2726f to your computer and use it in GitHub Desktop.
Save metametadata/9f1c205b8092d7b2726f to your computer and use it in GitHub Desktop.
core.async error handling using <? macro (ClojureScript)
(ns frontend.api.core
(:require [ajax.core :as ajax]
[cljs.core.async :refer [chan put! close!]]
[frontend.async.core :refer [channel-error]]))
(defn <?GET [url]
"Async. Returns a maybe-channel."
(let [<?chan (chan)]
(ajax/GET url
{:handler #(do (put! <?chan %)
(close! <?chan))
:error-handler #(do (put! <?chan (channel-error {:request-type :get
:url url
:details %}))
(close! <?chan))
})
<?chan))
(ns frontend.async.core)
(defrecord -MaybeChannelError [data])
(defn channel-error
"Creates error value which can be put into maybe-channel."
[data]
(-MaybeChannelError. data))
(defn channel-error?
"Checks if the argument is a maybe-channel error."
[v]
(instance? -MaybeChannelError v))
(defn channel-error-data
"Returns data from error value.
If value is not a maybe-channel error then nil is returned."
[e]
(if (channel-error? e)
(:data e)
nil))
(ns frontend.async.macros)
(defmacro <?
"<! for maybe-channels.
Gets a value from the maybe-channel but if it's a channel error then throws it instead."
[ch]
`(let [v# (cljs.core.async/<! ~ch)]
(if (frontend.async.core/channel-error? v#)
(throw v#)
v#)))
(defmacro <?go
"The same as |go| but returns a maybe-channel.
If exception is caught inside the body it will be put into the returned maybe-channel.
If exception is a channel error it will be put as is, all other caught objects (JS allows to throw any object)
will be wrapped into channel error."
[& body]
`(cljs.core.async.macros/go
(try
(do ~@body)
(catch js/Object e#
(if (frontend.async.core/channel-error? e#)
e#
(frontend.async.core/channel-error e#))))))
(defmacro chain
"Can be run only inside |go| or |<?go|.
Runs async operations sequentially, blocks until all operations are finished or exception is raised.
Returns a vector of results or throws a first channel error.
Operations must return maybe-channels or channels."
[& body]
(mapv #(list `<? %) body))
(defmacro chain-settle
"Can be run only inside |go| or |<?go|.
Runs async operations sequentially, blocks until all operations are finished.
Does not shortcircuit on the first channel error so that all the operations will be run.
Returns a vector of results (some of which can be channel errors).
Operations must return maybe-channels or channels."
[& body]
(mapv #(list 'cljs.core.async/<! %) body))
(defmacro <?chain
"Runs async operations sequentially without blocking.
Returns a maybe-channel which will eventually get a vector of operation results or a channel error.
Operations must return maybe-channels or channels."
[& body]
`(<?go (chain ~@body)))
(defmacro <chain-settle
"Runs async operations sequentially without blocking.
Does not shortcircuit on the first channel error so that all the operations will be run.
Returns a channel which will eventually get a vector of operation results (some of which can be channel errors)."
[& body]
`(cljs.core.async.macros/go (chain-settle ~@body)))
(defmacro all
"Can be run only inside |go| or |<?go|.
Runs async operations simultaneuosly, blocks until all operations are finished or channel error is received.
Returns a vector of results (order of operations is preserved) or throws a first channel error.
Operations must return maybe-channels or channels."
[& body]
`(let [channels# ~(vec (for [op body] op))
; atom/doseq/<? is used because for/<?, mapv/<?, etc. raise "Error: <! used not in (go ...) block"
results# (atom [])]
(doseq [ch# channels#]
(swap! results# conj (<? ch#)))
@results#))
(defmacro all-settle
"Can be run only inside |go| or |<?go|.
Runs async operations simultaneuosly, blocks until all operations are finished.
Does not shortcircuit on the first channel error so that all the operations will be run.
Returns a vector of results (some of which can be channel errors), order of operations is preserved.
Operations must return maybe-channels or channels."
[& body]
`(let [channels# ~(vec (for [op body] op))
results# (atom [])]
(doseq [ch# channels#]
(swap! results# conj (cljs.core.async/<! ch#)))
@results#))
(defmacro <?all
"Runs async operations simultaneuosly without blocking.
Returns a maybe-channel which will eventually get a vector of results (order of operations is preserved) or a channel error.
Operations must return maybe-channels or channels."
[& body]
`(<?go (all ~@body)))
(defmacro <all-settle
"Runs async operations simultaneuosly without blocking.
Does not shortcircuit on the first channel error so that all the operations will be run.
Returns a channel which will eventually get a vector of results (some of which can be channel errors), order of operations is preserved.
Operations must return maybe-channels or channels."
[& body]
`(cljs.core.async.macros/go (all-settle ~@body)))
"
Other exercises:
- implement the same functionality as functions instead of macros if possible, it may require changing signature, i.e.:
before:
(<?chain
(op1 1 2 3)
(op2 4 5 6))
after:
(<?chain
#(op1 1 2 3)
#(op2 4 5 6))
- alt/alts/first-like macro for maybe-channels which can throw exception if selected value is an error
- chain-like macro which returns all the values until first error + the error
"
(ns frontend.views.file.controller
(:require [frontend.views.file.view :as view]
[frontend.api.core :as api]
[frontend.router.core :as router]
[cljs.core.async :refer [chan put! <!]]
[frontend.async.core :refer [channel-error? channel-error-data]])
(:require-macros [cljs.core.async.macros :refer [go]]
[frontend.async.macros :refer [<? <?go
chain chain-settle
<?chain <chain-settle
all all-settle
<?all <all-settle]]))
(defn- <?load-files! [state]
(println " load-files!")
(<?go
(swap! state assoc :files-loading? true)
(swap! state assoc
:files (<? (api/<?GET "api/files/"))
:files-loading? false)
"<load-files result for debugging>"))
(defn- <?initialize-state! [state]
(println " >>> initialize-state!")
(swap! state merge {:files nil
:files-loading? nil
:current-file-id nil})
(<?load-files! state))
(defn- <?load-current-file-content! [state]
(<?go
(let [file-id (:current-file-id @state)]
(println " load-current-file-content!" file-id)
(when (not= file-id :none-file-id)
(swap! state assoc-in [:files file-id :loading?] true)
(swap! state assoc-in [:files file-id :content] (:items (<? (api/<?GET (str "api/files/" file-id)))))
(swap! state assoc-in [:files file-id :loading?] false))
"<load-current-file result for debugging>")))
(defn- <?set-current-file! [state file-id router]
(println " >>> set-current-file!" file-id)
; first make sure that file id exists
(let [file-id (if (contains? (:files @state) file-id)
file-id
:none-file-id)]
(router/push-tag router
(if (= file-id :none-file-id) :file-none :file)
{:file-id file-id})
(swap! state assoc :current-file-id file-id)
(<?load-current-file-content! state)))
(defrecord Controller [state router]
view/ControllerProtocol
(on-navigation [_ file-id]
(println "controller: on-navigation" file-id)
(go
(try
(chain
(<?initialize-state! state)
(<?set-current-file! state file-id router))
(catch js/Object e
(println "!!! ERROR - on-navigation - caught"
(if (channel-error? e)
(str "error from some channel, error data: " (channel-error-data e))
(str "js exception: " e)))))))
(on-change-current-file [_ file-id]
(println "controller: on-change-current-file" file-id)
(go
(try
; <? is needed to wait for result or raise an exception
(<? (<?set-current-file! state file-id router))
(catch js/Object e
(.error js/console "on-change-current-file exception:" e))))))
@metametadata
Copy link
Author

metametadata commented Jun 24, 2015

My try at implementing core.async error handling in ClojureScript as described in article by David Nolen:
http://swannodette.github.io/2013/08/31/asynchronous-error-handling/

Usage example is demonstrated in files:
frontend-api-core.cljs - shows how to create a "maybe-channel" for AJAX GET request
frontend-views-file-controller.cljs - see Controller record at the bottom of the file, it's method on-navigation is intended to be called when user enters the page and on-change-current-file will be triggered if user changes "current file" by using UI menu.

There's no built-in way to propagate errors in core.async. And one of the solutions is to manually pass special channel error values:

(channel-error {:type foobar-error})
(channel-error-data e) ; => {:type foobar-error}
(channel-error? e) ; => true

Based on these functions new macros are implemented:
<? is the same as <! but throws an exception if the incoming value is a channel error instance.
<?go is the same as go but also catches all errors in body and wraps them into channel error value to be put into returned channel.

Also implemented are macros for coordinating several async operations (see docstrings for more info).

chain   ; see usage example in Controller/on-navigation
chain-settle
<?chain
<chain-settle
all
all-settle
<?all
<all-settle

Naming convention is taken from the style guide by Eric Normand:
http://www.lispcast.com/core-async-code-style

Specifically prefix < is used for functions which return channels and <? is used for functions returning "maybe-channels" (i.e. the ones which can contain channel error values). The same prefixes are used for channel var names.

Other useful links:
https://github.com/alexanderkiel/async-error
http://martintrojer.github.io/clojure/2014/03/09/working-with-coreasync-exceptions-in-go-blocks/
http://wyegelwel.github.io/Error-Handling-with-Clojure-Async/
https://github.com/kachayev/async-errors
https://gist.github.com/ericnormand/6009721
https://gist.github.com/swannodette/6385166
https://github.com/fullcontact/full.async
https://github.com/funcool/promesa
https://github.com/jamesmacaulay/cljs-promises
http://clojurescriptmadeeasy.com/blog/promises-with-core-async.html
http://blog.venanti.us/using-transducers-with-core-async-clojurescript/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment