Why interceptors?

At Metosin, we have been thinking about interceptors in Clojure and ClojureScript. We’ve thought about them so much that in fact we made our own interceptor library for Clojure, called Sieppari1. To understand why, let’s take a look at the pros and cons of using interceptors instead of middleware.

Interceptors are a Clojure pattern, pioneered by the Pedestal framework, that replaces the middleware pattern used by Ring. In Pedestal, they are used for handling HTTP requests, but they can be used for handling all kinds of requests. For example in re-frame they’re used in handling web frontend events such as button clicks.

At Metosin, we’ve used them in a bunch of projects and we’re developing Sieppari to be used with reitit, our (latest) HTTP routing library.

Let’s work this out

In Ring, a HTTP request handler is a function that takes a request map and returns a response map. Something like this:

(defn my-handler [request]
  {:status 200,
   :headers {"Content-Type" "text/plain"},
   :body "hello!!"}))

To enhance the behavior of the handler in reusable way, you can wrap it with a higher-order function that takes the handler as parameter. This can be used to implement features like content encoding and decoding and authentication.

For example, here’s a debugging middleware that prints the incoming request map and the outgoing response map:

(defn print-middleware [handler]
  (fn [request]
    (prn :REQUEST request)
    (let [response (handler request)]
      (prn :RESPONSE response))))

The good thing about middleware is that they’re simple to implement: it’s just a Clojure function and you can use standard constructs such as try-catch. They’re fast, too.

The problems start when you try handle asynchronous operations. Ring specifies async handlers as functions that take callbacks for sending a response and raising an exception as arguments. We have to write a separate version of our debugging middleware for asynchronous handlers.

(defn debug-middleware [handler]
  (fn [request respond raise]
    (prn :REQUEST request)
    (handler request
             (fn [response]
               (prn :RESPONSE response)
               (respond response))
             raise)))

Using the same code for the synchronous and asynchronous handler is tricky and error handling gets difficult.

Interceptors offer a solution: you split the middleware in two phases2, :enter and :leave (or :before and :after as they’re called by re-frame). :enter is called before executing the handler, :leave is called afterwards. Both phases get a context map as a parameter and they return an updated context map. The request is under the key :request and the handler’s response is put under :response.

(def debug-interceptor
  {:enter
   (fn [{:keys [request] :as context}]
     (prn :REQUEST request)
     context)
   :leave
   (fn [{:keys [response] :as context}]
     (prn :RESPONSE response)
     context)})

Middleware can be composed by nesting function calls. With interceptors that does not work, so you need to have an executor that takes a chain of interceptors (called a queue) and executes them in order.

A cool thing you can now do is that if your interceptor returns an asynchronous result (a deferred or core.async channel for example), the executor can wait for it, and if the interceptor returns a synchronous result, the executor can act on it directly. This allows you to use the same interceptors for synchronous and asynchronous operations. The downside is that the executor is bound to be slower than nested function calls.

Another downside is that structures like try-catch and with-open do not work anymore. To allow proper error handling, interceptors have an optional :error phase that gets called if any of the inner interceptors throws an exception.

The queue as data

Middleware do not have to call the handler. For example, an authorization middleware may decide that a request is not authorized and instead of calling the handler, it returns an error response.

Interceptors go further: the remaining queue and the stack of the already-entered interceptors are exposed in the context map and you can manipulate them. If your authorization middleware wants to return early, you can (assoc context :queue []). Another example is that you can have a routing interceptor that pushes route-specific interceptors and a handler to the queue.

Finally, since your interceptor chain is now data instead of middleware-nesting code, you can do fancy tricks like dynamically re-order interceptors based on the dependencies between them. angel-interceptor is an implementation of this and Sieppari supports it as well. I’m a bit skeptical about whether there are real use cases for this, but it’s there if you need it.

Summary

  • Interceptors allow easy mixing of synchronous and asynchronous code.
  • Interceptors expose the queue and call stack as data, which gives you a fine-grained control over the execution.
  • Interceptors prevent you from doing error handling with try-catch – not that it would work well with asynchronous code anyway.
  • Interceptors are probably a bit slower than middleware.

  1. It’s not yet ready for production. At the time of writing, the latest release is 0.0.0-alpha5↩︎

  2. You can do this without interceptors, of course. See e.g. how the session middleware is implemented in the Ring codebase. ↩︎


Comments or questions? Send me an e-mail.