The macro above can be used to collect context about an exception and deliver it up the call stack to pass it along to a user or HTTP caller. For example:
(defn call-some-library []
(/ 2 (- 2 2))) ;; Intentional divide-by-zero error
(defn do-stuff []
(with-error-data {:more :data}
(call-some-library)))
(defn top-level []
(binding [*err-info* {}]
(try
(do-stuff)
(catch Exception ex
(prn :info (.getMessage ex) *err-info*)))))
I like how it doesn't obscure the original exception. But ChatGPT is unimpressed:
*err-info*
is our trusty dynamic variable, initially bound to an empty map.- with-error-data augments the current err-info with extra tidbits before running the code. If nothing goes wrong, it restores the old state.
- If something does go kaboom (like dividing by zero), the current context is still available for inspection in top-level.
The result? Somewhat of a magical breadcrumb trail for debugging. But is it perfect? Not quite. Let’s move on to the more idiomatic ex-info approach.
Clojure’s ex-info and ex-data are the poster children of idiomatic error handling. Here’s how we can rewrite the example:
(defn call-some-library []
(/ 2 (- 2 2))) ;; Still exploding intentionally
(defn do-stuff []
(try
(call-some-library)
(catch Exception ex
(throw (ex-info "Library error"
(merge (ex-data ex) {:more :data}) ex)))))
(defn top-level []
(try
(do-stuff)
(catch Exception ex
(prn :info (.getMessage ex) (ex-data ex)))))
Let’s walk through this upgrade:
- When call-some-library throws an exception, do-stuff catches it and wraps it in a new ex-info with additional context.
- The data from the original exception (if any) is merged with the new context, ensuring no breadcrumbs are lost.
- In top-level, you get a complete, layered view of what went wrong and why.
The real beauty of ex-info lies in its ability to chain exceptions. Each layer of your application can catch an exception, add context, and rethrow it. By the time the error bubbles up to a higher level, you have a detailed history of what went wrong, where, and why.
Dynamic vars? Not so much. Sure, they can carry state across calls, but if one function forgets to reset or update the state correctly, things get messy. You’re left playing detective, hunting for which layer of the stack fumbled the context.
ex-info keeps everything tidy and local. Each exception knows its data, and you don’t need to worry about global state leaking or being inadvertently clobbered. In contrast, the dynamic var approach relies on implicit, mutable state, which can lead to subtle bugs in larger systems.
Dynamic vars can sometimes feel like magic. Magic is cool—until you’re debugging and can’t figure out why err-info isn’t what you expect. With ex-info, there’s no guesswork: all the context is explicitly attached to the exception.
If you’re working in a small, simple codebase where you want quick-and-dirty context propagation, dynamic vars might work in a pinch. They’re not inherently bad—they just require careful handling.
But if you’re building something more complex or maintainable, ex-info is the way to go. It’s explicit, idiomatic, and scales beautifully with your application. Plus, nothing beats peeling back the layers of an ex-info-laden stack trace to see exactly what went wrong.
In short: embrace ex-info. Your future self will thank you. 🌟
ChatGPT prompts:
[original source code]
Very briefly summarize the code above. Then explain what it would be used for. Describe at least one alternative to achieve approximately the same thing. Contrast the approaches.
Write as a blog post, starting with the macro code. In the ex-info example, make sure to merge the new data into the caught exception's data. In the analysis, include some discussion of how the ex-info approach chains multiple exceptions in contrast to the dynamic var approach. Use a lighthearted tone.