As some may know, I've been experimenting with using continuation-passing-style to support asynchronous HTTP in Ring. An asynchronous handler accepts two arguments, the request, and a continuation function.
(defn handler [request cont]
(cont {:status 200, :headers {}, :body "Hello World"}))
The primary advantage of this approach is that it's backward compatible. We can support both the current synchronous design and the new asynchronous design at once:
(defn handler
([request]
{:status 200, :headers {}, :body "Hello World"})
([request cont]
(cont {:status 200, :headers {}, :body "Hello World"})))
This is currently being developed in the
cps branch of the Ring repository.
I've decided not to support non-blocking I/O for the body for the initial release of this feature. Instead the body will be written via a blocking OutputStream as before. While this is not ideal, most of the advantages of asynchronous HTTP remain.
The second problem is something I'd like to get feedback on, which is how to handle errors that occur outside the handler's thread. In the current system, we can simply throw an exception, but once we allow the response to be processed asynchronously, the error may occur outside the handler function's scope.
As I see it, there are a couple of possible solutions.
The first solution is to consider errors as just another response. We could return a response with a status code of 500, or return a raw exception instead of a map that can be later processed by middleware like wrap-stacktrace.
The second solution, and the one I'm currently favouring, is to pass a third argument to the handler that represents an error callback:
(defn handler [request cont raise]
(raise (Exception. "Something went wrong")))
The advantage of this approach is it provides a more straightforward way of short-circuiting response middleware. For instance, consider middleware that adds a header to the response:
(defn wrap-foo [handler]
([request]
(-> (handler request) (assoc-in [:headers "X-Foo"] "bar")))
([request cont]
(handler request #(assoc-in % [:headers "X-Foo"] "bar"))))
If we need to check the value of the response is not an error, we need to do so for each middleware:
(defn wrap-foo [handler]
([request]
(-> (handler request) (assoc-in [:headers "X-Foo"] "bar")))
([request cont]
(handler request #(if (map? %) (assoc-in % [:headers "X-Foo"] "bar") %))))
On the other hand, if we use an error callback, we can avoid repeating that logic in each wrapping of middleware:
(defn wrap-foo [handler]
([request]
(-> (handler request) (assoc-in [:headers "X-Foo"] "bar")))
([request cont raise]
(handler request #(assoc-in % [:headers "X-Foo"] "bar") raise)))
I believe this approach also dovetails well with
Manifold. Unless I'm very much mistaken, a function that returns a deferred from Manifold could be converted into a CPS handler just by passing the two callbacks to manifold.deferred/on-realized.
(defn ->handler [deferred-handler]
(fn [request cont raise]
(let [deferred-response (deferred-handler request)]
(d/on-realized deferred-response cont raise))))
I'd be very interested in hearing people's feedback, particularly regarding how to handle errors.
- James