dawcs/flow

Declarative exceptions handling


License
EPL-1.0

Documentation

flow CircleCI

Usage

Current Version

Consider (not really) trivial example:

(try
  (next-dangerous-action (dangerous-action arg)))
  (catch Exception e
    (log-error "action failed" e)
    (try (dangerous-fallback-action)
      (catch Exception e
        (log-error "fallback action failed" e)
        default-value))))

Not too readable. Let's add some flow to it:

(requre '[dawcs.flow :refer :all])

(->> (call dangerous-action arg)
     (then next-dangerous-action)
     (thru #(log-error "action failed" %))
     (else (fn [_] (call dangerous-fallback-action)))
     (thru #(log-error "fallback action failed" %))
     (else (constantly default-value)))

call is starting point to flow, it accepts a function and its arguments, wraps function call to try/catch block and returns either caught exception instance or call result:

(call / 1 0)
 => #error {
     :cause "Divide by zero"
     :via
     [{:type java.lang.ArithmeticException
       :message "Divide by zero"
       :at [clojure.lang.Numbers divide "Numbers.java" 158]}]
     :trace ...}

(call / 0 1) ;; => 0

Each next flow function works with exception instance as a value, so instead of throwing it, it just returns it:

then applies its first agrument(function) to its second agrument(value) if value is not an exception, otherwise it just returns that exception:

(->> (call / 1 0) (then inc)) => #error {:cause "Divide by zero" :via ...}
(->> (call / 0 1) (then inc)) => 1

else works as opposite, simply returning non-exception values and applying given function to value in case of exception:

(->> (call / 1 0) (else (comp :cause Throwable->map))) => "Divide by zero"
(->> (call / 0 1) (else (comp :cause Throwable->map))) => 0

thru works the same as else but always returns given value, so supplied function is called only for side-effects(like error logging):

(->> (call / 1 0) (else println)) => nil
(->> (call / 1 0) (thru println)) => #error {:cause "Divide by zero" :via ...}

IMPORTANT! then uses call under the hood, so if handler fails, error will be caught and passed through rest of pipeline. else and thru don't wrap handler to call, so you should do it manually if you need that behavior.

More real-life example:

;; some example dummy code
(defn find-entity [id]
  (if (some? id)
    {:id id :name "Jack" :role :admin}
    (fail "User not found" {:id id})))

(defn update-entity [entity data]
  (merge entity data))

(defn notify-slack [err]
  (prn "Slack notified:" err))

(defn format-response [data]
  {:status 200 :entity data})

(defn format-error [{:keys [cause data]}]
  {:status 500 :error cause :context data})

;; pipeline
(defn persist-changes [id updates]
  (->> (call find-entity id)
       (then #(update-entity % updates))
       (then format-response)
       (thru notify-slack)
       (else (comp format-error Throwable->map))))

(persist-changes 123 {:department "IT"})
;; => {:status 200, :entity {:id 123, :name "Jack", :role :admin, :department "IT"}}

(persist-changes nil {:department "IT"})
;; => {:status 500, :error "User not found", :context {:id nil}}

fail

Example above uses fail - simple wrapper around Clojure's core ex-info which allows to call it with single argument(passing empty map as second one). In addition there's fail? which checks if given value class is subclass of Throwable. Besides being a helper for constructing ex-info, fail is perfect tool for propagating errors without throw and try/catch. Function can simply return fail as a signal that something went wrong during processing, so then/else/thru will process it correctly:

(defn ratio [value total]
 (if (pos-int? total)
   (/ value total)
   (fail "Total should be positive int")))

(->> (ratio 1 0)
     (then inc)
     (thru prn)
     (else (constantly 0))) ;; => 0

(->> (ratio 0 1)
     (then inc)
     (thru prn)
     (else (constantly 0))) ;; => 1

flet

flet is exception-aware version of Clojure let. In case of exception thrown in bindings or body, it returns its instance, otherwise returns evaluation result. Let's rewrite previous example:

(defn perform-update [id updates]
  (flet [entity (find-entity id)
         updated-entity (update-entity entity updates)]
    (format-response updated-entity)))

(defn persist-changes [id updates]
  (->> (perform-update id updates)
       (thru notify-slack)
       (else (comp format-error Throwable->map))))

Tuning exceptions

call catches java.lang.Throwable by default, which may be not what you need, so this behavior can be changed:

(catch-from! java.lang.Exception)

;; dynamically define for a block of code
(catching java.lang.Exception (call / 1 0))

Some exceptions (like clojure.lang.ArityException) may signal about bad code or typo and throwing them may help to find it as early as possible, so it may be added to ignored exceptions list, so all pipeline functions will not catch these exceptions and it will throw:

(ignore-exceptions! #{IllegalArgumentException ClassCastException})

;; add without overwriting previous values
(add-ignored-exceptions! #{NullPointerException})

;; dynamically define for a block of code
(ignoring #{clojure.lang.ArityException} (call fail))

FAQ

Q: Is there an alternative to finally clause provided by flow?

A: No, finally is currently not implented. Consider using try/catch/finally if you need that.

Q: How about cljs support?

A: cljs is not supported at the moment. Feel free to open PR if you need it.

API docs

https://dawcs.github.io/flow

License

Copyright © 2018 DAWCS

Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.