Skip to content

Commit 5ab91f2

Browse files
soveltenjoaopluigiaredington
authored
Add with-try macro to add a global exception handler to nodes within an env (#51)
* update node logic * add generated llm claude-4-sonnet tests * review generated llm claude-4-sonnet tests * add generated llm tests on api-test namespace * rm comment code * fix lint * add first version of try-env macro * wip: completable future antics * passings tests with cf based function composition allows (but doesn't test) exception catching composition * Append to tail not head * Get rid of type shenanigans, just add catching and indirection * write test for with-error-handler, try to work on try-macro (wip) * fix test * initial implementation of with-try macro * add working version of with-try macro with some initial tests * Quote prevents aliases from resolving * lint fix * clean up code and make tests use engine-test-suite * fix lint * Update nodely to 2.0.3 * Update CHANGELOG.md * Make impl details private, add docstring to `with-try` * Document `with-try` in Readme --------- Co-authored-by: joaopluigi <[email protected]> Co-authored-by: Alex Redington <[email protected]>
1 parent 7fce2e1 commit 5ab91f2

File tree

8 files changed

+233
-3
lines changed

8 files changed

+233
-3
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 2.0.3 / 2024-11-14
4+
- Add with-try macro to nodely.api.v0, enabling adding an exception handler globally to an environment.
5+
36
## 2.0.2 / 2024-11-14
47
- Remove the default engine behavior in internal applicative eval functions
58

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,30 @@ Let's say that `x` and `y` are actually expensive http requests. What we want is
179179

180180
This will evaluate the node by realizing all required dependencies, without executing the dependencies that are not needed. For instance, if x is even, y is not evaluated at all.
181181

182+
#### Environment Defined Exception Handling
183+
184+
Nodely offers the `with-try` macro to provide an environment-level
185+
policy for exception handling. This tool is essentially coarse grained
186+
and will apply an exception handler to every leaf, branch, and
187+
sequence node in the environment provided to it. The syntax mirrors
188+
that of Clojure's `(try ... (catch ...))` special forms, e.g.
189+
190+
```clojure
191+
(with-try {:a (>leaf (/ 5 ?b))
192+
:b (>value 0)}
193+
(catch ArithmeticException _ Double/NaN)
194+
(catch NullPointerException _ 0))
195+
```
196+
197+
Multiple catch clauses may be specified to perform type based
198+
dispatching of which expression an exceptional case should
199+
trigger.
200+
201+
This is offered as a tool for setting exceptional policy independently
202+
of specifying environments; Nodely clients are advised to prefer
203+
handling exceptional cases explicitly in the implementations of leaf,
204+
branch and sequence functions when possible.
205+
182206
#### Testing
183207

184208
Using `eval-node-with-values` is the most straightforward way to evaluate a node by supplying actual values for the data dependencies. If we want to test `branch-node` without actually making the expensive calls, it is as simple as that:

project.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
(defproject dev.nu/nodely "2.0.2"
1+
(defproject dev.nu/nodely "2.0.3"
22
:description "Decoupling data fetching from data dependency declaration"
33
:url "https://github.com/nubank/nodely"
44
:license {:name "MIT"}
55

66
:plugins [[s3-wagon-private "1.3.4" :exclusions [com.fasterxml.jackson.core/jackson-core]]
77
[com.fasterxml.jackson.core/jackson-core "2.12.4"]]
88

9-
:dependencies [[org.clojure/clojure "1.10.3"]
9+
:dependencies [[org.clojure/clojure "1.12.0"]
1010
[aysylu/loom "1.0.2"]
1111
[org.clojure/core.async "1.5.648" :scope "provided"]
1212
[funcool/promesa "10.0.594" :scope "provided"]

src/nodely/api/v0.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
nodely.data/leaf
2121
nodely.data/sequence
2222
nodely.data/branch
23+
nodely.data/with-try
2324
engine-core/checked-env)
2425

2526
(import-fn nodely.engine.lazy/eval-node-with-values eval-node-with-values)

src/nodely/data.clj

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,116 @@
116116
:sequence (recur (::process-node node)
117117
(conj inputs (::input node))))))
118118

119+
(defn catching
120+
"Like `clojure.core/comp`, except only affects throw completions of
121+
the wrapped `g`. `f` must be a function of a single Exception, and
122+
will be passed the exception thrown from `g` if `g` completes
123+
exceptionally."
124+
[f g]
125+
(fn [& args]
126+
(try (apply g args)
127+
(catch Throwable t (f t)))))
128+
129+
(defn update-leaf
130+
[leaf f compositor]
131+
;; f new function, arg is old function
132+
(update leaf :nodely.data/fn #(compositor f %)))
133+
134+
(declare env-update-helper)
135+
136+
(defn update-branch
137+
[{::keys [condition truthy falsey]}
138+
f
139+
{:keys [apply-to-condition?]
140+
:or {apply-to-condition? false} :as opts}
141+
compositor]
142+
#::{:type :branch
143+
:condition (if apply-to-condition?
144+
(env-update-helper condition f opts compositor)
145+
condition)
146+
:falsey (env-update-helper falsey f opts compositor)
147+
:truthy (env-update-helper truthy f opts compositor)})
148+
149+
(defn update-sequence
150+
[sequence f compositor]
151+
(update sequence ::process-node env-update-helper f {} compositor))
152+
153+
(defn env-update-helper
154+
[node f opts compositor]
155+
(case (::type node)
156+
:value (update node ::value f)
157+
:leaf (update-leaf node f compositor)
158+
:branch (update-branch node f opts compositor)
159+
:sequence (update-sequence node f compositor)))
160+
161+
(defn update-node
162+
([node f opts]
163+
(env-update-helper node f opts comp))
164+
([node f]
165+
(update-node node f {})))
166+
167+
(defn catch-node
168+
([node f opts]
169+
(env-update-helper node f opts catching))
170+
([node f]
171+
(catch-node node f {})))
172+
119173
;;
120174
;; Env Utils
121175
;;
122176

177+
(defn with-error-handler
178+
[env handler]
179+
(update-vals env #(catch-node % handler {:apply-to-condition? true})))
180+
181+
(defn- tuple-to-handler
182+
[m]
183+
(fn [error]
184+
(if-let [f (some (fn [[ex-class handler]] (when (instance? ex-class error) handler)) m)]
185+
(f error)
186+
(throw error))))
187+
188+
(defn- with-try-expr
189+
[clauses]
190+
(let [clauses (into [] (for [[c t s expr] clauses]
191+
(do (assert (= c 'catch))
192+
(if-let [t (resolve t)]
193+
[t (eval `(fn [~s] ~expr))]
194+
(throw (ex-info (str "Could not resolve exception class: " t) {:type t}))))))]
195+
clauses))
196+
197+
(defmacro with-try
198+
"Macro
199+
200+
`with-try` will apply an error handling semantic to every leaf,
201+
branch, and sequence node in a provided environment. If any such
202+
nodes throw an exception, the catch expressions described in the
203+
body of `with-try` will be evaluated. The resulting value of the
204+
matching catch clause will become the value of the node which threw
205+
the exception.
206+
207+
`with-try` has syntax equivalent to Clojure's `try` special form for
208+
`catch` clauses but does not currently support use of a `finally`
209+
clause.
210+
211+
`with-try` creates a policy across an entire environment
212+
indiscriminately, when it is possible, clients are advised to catch
213+
exceptional cases in the implementations of leaf/branch/sequence
214+
nodes explicitly.
215+
216+
example:
217+
218+
(with-try {:a (>leaf (/ 5 ?b))
219+
:b (>value 0)}
220+
(catch ArithmeticException _ Double/NaN)
221+
(catch NullPointerException _ 0))
222+
223+
will result in `:a` evaluating to 0"
224+
[env & body]
225+
`(with-error-handler
226+
~env
227+
(tuple-to-handler ~(with-try-expr body))))
228+
123229
(s/defn get-value :- s/Any
124230
[env :- Env
125231
k :- s/Keyword]

src/nodely/engine/applicative.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,4 @@
9797
(merge env
9898
(reduce (fn [acc [k v]] (assoc acc k (protocols/-extract v)))
9999
{}
100-
(lazy-env/scheduled-nodes lazy-env)))))
100+
(lazy-env/scheduled-nodes lazy-env)))))

test/nodely/api_test.clj

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,40 @@
246246
:supported-engine-names set?}
247247
(try (api/eval-node-channel env (>leaf (inc ?z)) {::api/engine :core.async.doesnt-exist})
248248
(catch clojure.lang.ExceptionInfo e (ex-data e)))))))
249+
250+
(defn with-try-engine-test-suite
251+
[engine-key]
252+
(t/testing (name engine-key)
253+
(t/testing "Exception types must be resolvable at with-try expansion time"
254+
(t/matching "Could not resolve exception class: NonSenseException"
255+
(try
256+
(eval '(nodely.api.v0/with-try exceptions-all-the-way-down
257+
(catch NonSenseException _ 0)))
258+
(catch clojure.lang.Compiler$CompilerException e
259+
(ex-message (.getCause e))))))
260+
261+
(t/testing "with-try catches exceptions informed by class inheritance and catch clause ordering"
262+
(t/matching 0
263+
(api/eval-key
264+
(api/with-try exceptions-all-the-way-down
265+
(catch Throwable _ -3)
266+
(catch clojure.lang.ExceptionInfo _ 0))
267+
:d {::api/engine engine-key})))
268+
269+
(t/testing "with-try order of clauses can preempt inheritance"
270+
(t/matching 0
271+
(api/eval-key
272+
(api/with-try exceptions-all-the-way-down
273+
(catch clojure.lang.ExceptionInfo _ -3)
274+
(catch Throwable _ 0))
275+
:d {::api/engine engine-key})))))
276+
277+
(t/deftest with-try
278+
(let [remove-keys (conj #{:core-async.iterative-scheduling
279+
:async.virtual-futures}
280+
(when (try (import java.util.concurrent.ThreadPerTaskExecutor)
281+
(catch Throwable t t))
282+
:applicative.virtual-future))]
283+
(for [engine (set/difference (set (keys api/engine-data))
284+
remove-keys)]
285+
(with-try-engine-test-suite engine))))

test/nodely/data_test.clj

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,62 @@
2626
(data/leaf [:a] identity))
2727
(data/leaf [:b] identity)
2828
(data/leaf [:c] identity)))))))
29+
30+
(deftest update-node-test
31+
(testing "update-node with value node"
32+
(let [value-node (data/value 42)
33+
updated-node (data/update-node value-node (partial * 2))]
34+
(is (= (::data/type updated-node) :value))
35+
(is (= 84 (::data/value updated-node))))) ; (* 2 42) = 84
36+
37+
(testing "update-node with leaf node"
38+
(let [leaf-node (data/leaf [:x] (comp inc :x))
39+
updated-node (data/update-node leaf-node (partial * 2))]
40+
(is (= (::data/type updated-node) :leaf))
41+
(is (= (::data/inputs updated-node) #{:x}))
42+
(is (= 12 ((::data/fn updated-node) {:x 5}))))) ; (* 2 (inc 5)) = 12
43+
44+
(testing "update-node with branch node"
45+
(let [condition (data/leaf [:x] (comp even? :x))
46+
truthy (data/value 10)
47+
falsey (data/value 20)
48+
branch-node (data/branch condition truthy falsey)
49+
updated-node (data/update-node branch-node (partial * 2))]
50+
(is (= (::data/type updated-node) :branch))
51+
;; Condition should not be updated by default
52+
(is (= (::data/condition updated-node) condition))
53+
;; Truthy and falsey should be updated
54+
(is (= 20 (::data/value (::data/truthy updated-node)))) ; (* 2 10) = 20
55+
(is (= 40 (::data/value (::data/falsey updated-node)))))) ; (* 2 20) = 40
56+
57+
(testing "update-node with branch node and apply-to-condition option"
58+
(let [condition (data/leaf [:x] (comp inc :x))
59+
truthy (data/value 10)
60+
falsey (data/value 20)
61+
branch-node (data/branch condition truthy falsey)
62+
updated-node (data/update-node branch-node (partial * 2) {:apply-to-condition? true})]
63+
(is (= (::data/type updated-node) :branch))
64+
;; All parts should be updated
65+
(is (= 12 ((::data/fn (::data/condition updated-node)) {:x 5}))) ; (* 2 (inc 5)) = 12
66+
(is (= 20 (::data/value (::data/truthy updated-node)))) ; (* 2 10) = 20
67+
(is (= 40 (::data/value (::data/falsey updated-node)))))) ; (* 2 20) = 40
68+
69+
(testing "update-node with sequence node"
70+
(let [sequence-node (data/sequence :items 5)
71+
updated-node (data/update-node sequence-node (partial * 2))]
72+
(is (= (::data/type updated-node) :sequence))
73+
(is (= (::data/input updated-node) :items))
74+
(is (= 10 (::data/value (::data/process-node updated-node)))))) ; (* 2 5) = 10
75+
76+
(testing "update-node with 2-arity (no options)"
77+
(let [leaf-node (data/leaf [:x] (comp inc :x))
78+
updated-node (data/update-node leaf-node (partial * 2))]
79+
(is (= (::data/type updated-node) :leaf))
80+
(is (= (::data/inputs updated-node) #{:x}))
81+
(is (= 12 ((::data/fn updated-node) {:x 5}))))))
82+
83+
(deftest with-error-handler-test
84+
(testing "with-error-handler wraps leaf functions to handle thrown exceptions"
85+
(let [throw-env {:a (data/leaf #{} (fn [] (throw (ex-info "OOps" {}))))}
86+
handled-env (data/with-error-handler throw-env (fn [^Throwable _] :handled))]
87+
(is (= :handled ((::data/fn (get handled-env :a))))))))

0 commit comments

Comments
 (0)