Simple error handling using slingshot and clj-http

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:

  1. treated exceptions as exceptions
  2. was general enough to leverage `clj-http` exception
  3. 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 status
code. Returns the body of a response."
  [req-fn url options]
   (: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

  1. Does this strike you as simple and general error handling?
  2. If yes, why is it objectively simpler and how do we measure it?
  3. How would you do it differently?

2 responses
One of the argument against this solution would be functional purity. Adam Bard point of view :
@Ivan: That's definitely a valid, and maybe even better, alternative. I came across that post when looking for idiomatic ways to do error-handling, and the biggest reason for not going down that route is that it seems to be non-standard in the Clojure ecosystem, and this type of error handling strikes me as more orthogonal. The fact that it's (probably, at least right now) platform-specific and not functionally pure is definitely a negative though, and I really like the `val, err` pattern in Golang!