Slippery exceptions in Clojure and Ruby
Recently I spent a couple of hours banging my head against code that looks like this:
(defn parse-file
[contents]
(remove nil?
(code-that throws-an-exception)))
(defn consume-manifest
[contents kind]
(try+
(parse-file kind contents)
(catch java.lang.Exception e
(throw+ {:type ::bad-parse :message "Invalid file."}))))
(defn check
[file kind]
(try+
(let [artifacts (consume-manifest (slurp file) kind]
(if (not-empty artifacts)
… etc
And much to my surprise, I kept getting the kind of exception parse-file
generates deep within the check
function, right up against (not-empty artifacts)
.
I’ve grown somewhat used to Clojure exceptions being unhelpful, but this was taking the cake. Coming from Ruby and pretty much every other language, this brushed up rudely against my expectations.
You can tell that exceptions in Clojure are unloved, given how cumbersome handling them natively is. We’d had some trouble in the past getting slingshot to behave properly, so I zero'ed in on there. Don’t all exceptions in Java descend from Exception
?
Stepping through check
in the Cursive debugger, I could see that the exception generated was a pure java exception, not a slingshot exception generated by throw+
in consume-manifest
. This meant that the exception was slipping straight through uncaught. But calling consume-manifest
directly in my repl was causing it to work as intended.
What the hell was going on?
Max took one look at it and set me straight. “Oh. remove
is lazy, so the exception isn’t being throw until the lazy sequence is accessed.”
Excuse me? I had an angry expression on my face. He looked sheepish.
“How else would a lazy data structure work?”
Well. I would expect a catch java.lang.Exception
to catch every exception.
“Right, well, hear me out. What if you had the following Ruby?”
def lazy_parse(filename)
File.open(filename).each_line.each_with_index.lazy.map do |line, i|
raise "You can't catch me, I'm the exception man" if i == 5
line
end
end
def consume_file
begin
lazy_parse("Gemfile.lock")
rescue
puts "Woops, an exception. Good thing we caught it."
end
end
file = consume_file
puts file.first(10)
(Did you know that Ruby has had lazy enumerables for almost four years now? Worth reading Shaughnessy as well.)
That shut me up good. And in case you were wondering, the stack trace is also useless in Ruby; there simply isn’t any context for it to preserve. Frankly, I’ve just never had to think about lazy data structures in Rubyland; they’ve not been super popular.
It’s hard to reason about this. I want to write wrapper functions that make my code safe to consume downstream. This isn’t feasible for any functions iterating over potentially infinite lazy sequences, but fortunately for us we need to fit this file into memory anyways. In Ruby we’d have to forcibly iterate over every element of the sequence and check for exceptions, but Clojure makes this easy with doall
:
(defn parse-file
[contents]
(doall (remove nil?
(code-that throws-an-exception))))
And now, things behave as intended.