Lately I've been been thinking about simplicity in software. When I say simple I mean: not compound. This is different from easy, which is a measure of familiarity. If this distinction is unfamiliar to you, I recommend you stop reading this and go watch Rich Hickey's talk Simple made easy first. With that said, let's turn to the subject matter at hand: What does simple error handling look like?
I'm making a library which acts as a wrapper for a HTTP API. The code is using `slingshot` for enhanced error handling (try+ and throw+) and `clj-http` for HTTP requests. Depending on if the user is authenticated or if the request has the right format, the API gives different errors. In the case of a bad request, there's usually some good information inside the JSON body under a key called "additional". How should this be handled?
This is what I stated off with. I turned off exceptions for `clj-http` and wrapped the response body in every function of type {get,post,put}-<foo-resource>. Not only is it bad code smell due to the duplication of code, it's not treating exceptions as exception - if the user expects a <foo-resource>, giving them an arbitrary error map leads to special cases propagating throughout the code.
(defn handle-errors [{:keys [status body opts error] :as resp}](cond error {:error error :opts opts}(= status 401) {:error status :body body}(= status 400) {:error status :additional (:additional (json/parse-string body true))}:else {:status status :body (json/parse-string body true)}))
I wanted a simpler solution that:
- treated exceptions as exceptions
- was general enough to leverage `clj-http` exception
- gave informative error messages in the right place
A quick side note on exceptions vs happy paths everywhere. Some will say a bad response isn't an exception, but is something to be expected. I agree. This is something that should be handled at the app level though, and not the library level - if I get a 401 as an end user I expect to be able to login again, but if I'm writing an app I want to see what's wrong and be able to control the flow myself.)
I turned on exceptions for clj-http and started to use slingshot to deal with throwing the right exceptions. Instead of having the `handle-error` function in every {get,post,put} function for a specific resource, I put a `handle-response` function in the general {get,post,put}-functions. The error messages from the API are general and should be treated as such.
(defn post-resource"POST params map to resource url."[url params](let [options (assoc (make-options-map) :form-params params)](handle-response client/post url options)))
Since clj-http will throw exceptions for all kinds of bad responses, like 5xx, the error will definitely propagate. The only difference is that I can customize and dispatch the error messages. It's very straightforward to add another error to catch, or to ignore an error and dispatch it to a special case function.
(defn handle-response"Tries to make a request and throws custom errors based on statuscode. Returns the body of a response."[req-fn url options](try+(:body (req-fn url options))(catch [:status 401] {:keys [body]}(throw+ "access denied"))(catch [:status 400] {:keys [body]}(throw+ (get (json/parse-string body) "additional")))))
This is a much simpler solution. Beforehand I wasn't too familiar with using java try/throw/catch exception handling - mostly because I felt uneasy using something "that Java". In hindsight this strikes me as a more general, elegant and simpler solution for error handling.
Questions for the reader
- Does this strike you as simple and general error handling?
- If yes, why is it objectively simpler and how do we measure it?
- How would you do it differently?