Sunday, October 18, 2009

Twisted Web in 60 seconds: interrupted responses


Welcome to the eleventh installment of "Twisted Web in 60 seconds". Previously I gave an example of a Resource which generates its response asynchronously rather than immediately upon the call to its render method. When generating responses asynchronously, the possibility is introduced that the connection to the client may be lost before the response is generated. In such a case, it is often desirable to abandon the response generation entirely, since there is nothing to do with the data once it is produced. In this installment, I'll show you how to be notified that the connection has been lost.




This example will build upon the example from installment nine which simply (if not very realistically) generated its response after a fixed delay. I will expand that resource so that as soon as the client connection is lost, the delayed event is canceled and the response is never generated.




The feature this example relies on is provided by another Request method: notifyFinish. This method returns a new Deferred which will fire with None if the request is successfully responded to or with an error otherwise - for example if the connection is lost before the response is sent.




The example starts in a familiar way, with the requisite Twisted imports and a resource class with the same _delayedRender used previously:



  from twisted.web.resource import Resource
 from twisted.web.server import NOT_DONE_YET
 from twisted.internet import reactor

 class DelayedResource(Resource):
     def _delayedRender(self, request):
         request.write("<html><body>Sorry to keep you waiting.</body></html>")
         request.finish()



Before defining the render method, I'm going to define an errback (an errback being a callback that gets called when there's an error), though. This will be the errback attached to the Deferred returned by Request.notifyFinish. It will cancel the delayed call to _delayedRender.



      def _responseFailed(self, err, call):
         call.cancel()



Finally, the render method will set up the delayed call just as it did before, and return NOT_DONE_YET likewise. However, it will also use Request.notifyFinish to make sure _responseFailed is called if appropriate.



      def render_GET(self, request):
         call = reactor.callLater(5, self._delayedRender, request)
         request.notifyFinish().addErrback(self._responseFailed, call)
         return NOT_DONE_YET



Notice that since _responseFailed needs a reference to the delayed call object in order to cancel it, I passed that object to addErrback. Any additional arguments passed to addErrback (or addCallback) will be passed along to the errback after the Failure instance which is always passed as the first argument. Passing call here means it will be passed to _responseFailed, where it is expected and required.




That covers almost all the code for this example. Here's the entire example without interruptions, as an rpy script:



from twisted.web.resource import Resource
from twisted.web.server import NOT_DONE_YET
from twisted.internet import reactor

class DelayedResource(Resource):
   def _delayedRender(self, request):
       request.write("Sorry to keep you waiting.")
       request.finish()

   def _responseFailed(self, err, call):
       call.cancel()

   def render_GET(self, request):
       call = reactor.callLater(5, self._delayedRender, request)
       request.notifyFinish().addErrback(self._responseFailed, call)
       return NOT_DONE_YET

resource = DelayedResource()



Toss this into example.rpy, fire it up with twistd -n web --path ., and hit http://localhost:8080/example.rpy. If you wait five seconds, you'll get the page content. If you interrupt the request before then, say by hitting escape (in Firefox, at least), then you'll see perhaps the most boring demonstration ever - no page content, and nothing in the server logs. Success!




Next time I'll digress slightly to cover the basics of Twisted logging and expand this example to use it to show when clients fail to receive the response they requested.

9 comments:

  1. Seriously, this series is starting to shape up as the best Twisted example/documentation series in existence, thanks so much for this!

    What I'm really excited about is you're now diving into more advanced use-cases and features, and I think that is missing with respect to twisted.web. Please strongly consider touching on things like Sessions and Authentication, and other topics like url dispatchers and interacting with databases.

    Twisted has great implementations of so many protocols, but I believe that having the ability to easily create Web facing endpoints to multi-protocol apps is a killer feature for Twisted, especially with the rapidly increasing interest in Comet applications.

    Thanks again for this awesome series of posts. Please keep them coming so I can continue to blast them out on twitter, tell all my friends, and anxiously check my rss reader for the next edition.

    ReplyDelete
  2. Thanks for the feedback and the encouragement. :)

    Sessions and authentication are definitely important topics and I'll try to cover them.

    ReplyDelete
  3. Hi JP

    One quick question: what happens if the client goes away and there are no callbacks added via notifyFinish? Presumably the service just proceeds as normal and calls request.write with the no-longer-relevant data. And I guess the request object gets the data to the protocol, which is smart enough to know that the connection has been lost, and so tosses it? I'm mainly wondering if some sort of exception is raised. I guess not as I've never seen one (but then again, who would dare not wait for my fabulous content?).

    Terry

    ReplyDelete
  4. Hey Terry,

    Good question. For a long time, it was basically just as you say. However, as of 9.0, this has changed and you will get an exception. Request.write will still silently accept your bytes and discard them (somewhat by accident of the implementation). However, when you get to calling Request.finish, you'll get an exception back:

    raise RuntimeError(
    "Request.finish called on a request after its connection was lost; "
    "use Request.notifyFinish to keep track of this.")

    Hopefully this will motivate people to write code that pays attention to the state of the connection. Also, if we ever manage to add proper producer/consumer support here, things will be a bit nicer: your producer will just be told to `stopProducing` when the connection is lost, and that will be the end of it (you won't have any explicit calls to Request.finish in that case).

    ReplyDelete
  5. Yes, that's good. I wondered about that possibility after I hit send. I like it when things force me to deal with situations that I might have been taking for granted.

    BTW, this is a good example (IMO) of where you could use the ControllableDeferred class I wrote the other day. You'd do something like this (pseudo-code, as ever):

    call = ControllableDeferred(someFunc, arg, arg, etc)
    request.notifyFinish().addErrback(lambda _: call.deactivate())

    in which case if the client disconnected, the deferred would simply never fire. I find it very clean, very simple.

    ReplyDelete
  6. > BTW, this is a good example (IMO) of where you could use the ControllableDeferred

    True. A cancelable deferred would work pretty well, too. ;)

    ReplyDelete
  7. How would you accomplish this canceling with the code from the tenth installment rather than the ninth?

    ReplyDelete
  8. Let's recall the render_GET method from the 10th installment:

    def render_GET(self, request):
    d = deferLater(reactor, 5, lambda: request)
    d.addCallback(self._delayedRender)
    return NOT_DONE_YET

    Here the asynchronous operation is represented by the Deferred returned by deferLater. This differs from the 9th installment, in which the asynchronous operation was represented by a DelayedCall. Since DelayedCall has a "cancel" method, it's clear how to abort the asynchronous operation in this version. But even though the asynchronous operation created by deferLater is really the very same one (reactor.callLater is used by deferLater to accomplish the result), there is no Deferred.cancel. With no direct access to the DelayedCall, there's no way to cancel it.

    However, starting in Twisted 10.1, Deferreds will provide an API for cancelation. When a Deferred is created, its creator will have an opportunity to specify what happens when it is canceled. deferLater will use this opportunity to specify that the underlying DelayedCall be canceled when the Deferred is canceled. So, in 10.1, you'll be able to do this:

    def _responseFailed(self, err, deferred):
    deferred.cancel()

    def render_GET(self, request):
    d = deferLater(reactor, 5, lambda: request)
    d.addCallback(self._delayedRender)
    request.notifyFinish().addErrback(self._responseFailed, d)
    return NOT_DONE_YET

    This will cause the Deferred to be canceled if the response is interrupted. Canceling the Deferred in turn cancels the DelayedCall. One extra thing to note, though, is that the canceled Deferred will fire with a cancellation error. To avoid having that logged as unhandled, it should be trapped and silenced as well:

    def _responseFailed(self, err, deferred):
    deferred.cancel()

    def _delayedCallFailed(self, err):
    err.trap(defer.CancelledError)

    def render_GET(self, request):
    d = deferLater(reactor, 5, lambda: request)
    d.addCallbacks(self._delayedRender, self._delayedCallFailed)
    request.notifyFinish().addErrback(self._responseFailed, d)
    return NOT_DONE_YET

    Cancellation is defined on a per-Deferred basis. This works for deferLater because deferLater explicitly supports it (or rather, will in 10.1). Check the API docs for other Deferred-returning APIs to see if they support cancellation.

    ReplyDelete
  9. HI Jp,
    Your knowledge of twisted is amazing and matched only by your ability to show simple examples to programmers like us.
    I face much of the issues here since i am using twisted to write a new mail server complete with smtp, imap, pop3 and a Storageengine that is twisted http based.

    ** btw, the imap bodystructure that you wrote in imap4.py, do u ever plan to update it, it doesnt work with majority popular clients. I have replaced that part with own code but was hopeful that you will update the twisted one.

    marc.

    ReplyDelete