class MessageServerClient(object): _BACKOFF_INTERVALS = (1, 1, 1, 5, 10, 30, 60) _MAX_BUFFER_SIZE = 1000 _RECEIVE_TIMEOUT = 2 def __init__(self, address): ''' MessageServerClient constructor. @param address: address to send the messages to @type address: str ''' self._address = 'tcp://%s' % address self._socket = None self._context = None self._queue = Queue() self._processQueueInLoop() @property def address(self): return self._address def ping(self): ''' Sends a ping message to the server and checks if it answers. @return: True when the server answered, False otherwise @rtype: bool ''' return self._send('ping', receiveTimeout=1) def send(self, message): ''' Puts a message in the queue and immediately after that yields. This will give the worker which is running in a Greenlet the time to process the queue. @param message: message to send to the server @type message: str ''' self._queue.put(message) gevent.sleep() @property def _isConnected(self): ''' Returns a flag that states if the client is connected or not. @return: flag that states if the client is connected or not @rtype: bool ''' if self._context: return not self._context.closed else: return False def _connect(self): '''Connects the client if it isn't already''' if self._isConnected: printInDebugMode('Can\'t connect to %s, already connected' % self._address) return printInDebugMode('Connecting to %s' % self._address) self._context = zmq.Context(2) self._socket = self._context.socket(zmq.REQ) self._socket.setsockopt(zmq.LINGER, 0) self._socket.connect(self._address) printInDebugMode('Connected to %s' % self._address) def _disconnect(self): '''Disconnects the client if it isn't already''' if not self._isConnected: printInDebugMode( 'Can\'t disconnect from %s, already disconnected' % self._address) return printInDebugMode('Disconnecting from %s' % self._address) self._socket.close() self._context.term() printInDebugMode('Disconnected from %s' % self._address) def _processQueue(self): ''' Tries to process as many as possible queued messages. A message buffer is updated on every attempt. This buffer is then flattened to a single message. This reduces the amount of roundtrips with the server. When a message is not send succesfully a series of retry attempts will be completed before aborting. After every failed attempt the client backs off for a fixed amound of seconds. @return: True when a part of the queue is succesfully processes, False otherwise @rtype: bool ''' maxAttempts = len(self._BACKOFF_INTERVALS) + 1 isSucces = False buffer = list() for attempt in range(1, maxAttempts): buffer = self._updateBuffer(buffer) message = ''.join( buffer) # The message is actually a batch of messages. isSucces = self._send(message) if isSucces: break else: interval = self._BACKOFF_INTERVALS[attempt - 1] printInDebugMode( '''\ Failed to send message to %(address)s in %(attempts)d attempt(s)' Retrying to send message to %(address)s in %(interval)d seconds''' % { 'address': self._address, 'attempts': attempt, 'interval': interval }) gevent.sleep(interval) if isSucces: printInDebugMode( 'Successfully sent message to %s in %d attempt(s)' % (self._address, attempt)) else: printInDebugMode('Failed to send message to %s in %d attempt(s)' % (self._address, attempt)) return isSucces def _processQueueInLoop(self): ''' Spawns a worker in a Greenlet that processes the queue when possible. ''' def worker(): while True: self._processQueue() gevent.spawn(worker) def _updateBuffer(self, buffer): ''' Updates a buffer by moving as much messages as possible from the queue to the buffer. This method will block if the queue is empty. Once a message is put in the queue the buffer will be updated. @param buffer: buffer to update @type buffer: set() @return: updated buffer @rtype: set() ''' while len(buffer) < self._MAX_BUFFER_SIZE: # The get call on the queue will block until a message is put in the # queue. message = self._queue.get() buffer.append(message) if self._queue.empty(): break return buffer def _send(self, message, receiveTimeout=_RECEIVE_TIMEOUT): ''' Tries to send a message to the server and waits a fixed amount of seconds for it to answer. @param message: message to send to the server @type message: str @param receiveTimeout: max time in seconds in which the server should respond @type receiveTimeout: int ''' if not self._isConnected: self._connect() try: self._socket.send(message) except Exception, e: self._disconnect() printInDebugMode('Couldn\'t send message, unexpected exception' ' while sending to server: %s' % e) result = None timeout = Timeout(receiveTimeout) timeout.start() try: result = self._socket.recv() except Timeout, timeoutException: if timeoutException == timeout: printInDebugMode( 'Couldn\'t send message, server response timed' ' out after %d seconds' % receiveTimeout) self._disconnect()
def wsgi_app(self, environ, start_response): """Execute this instance as a WSGI application. See the PEP for the meaning of parameters. The separation of __call__ and wsgi_app eases the insertion of middlewares. """ request = Request(environ) request.encoding_errors = "strict" # The problem here is that we'd like to send an infinite stream # of events, but WSGI has been designed to handle only finite # responses. Hence, to do this we will have to "abuse" the API # a little. This works well with gevent's pywsgi implementation # but it may not with others (still PEP-compliant). Therefore, # just to be extra-safe, we will terminate the response anyway, # after a long timeout, to make it finite. # The first such "hack" is the mechanism to trigger the chunked # transfer-encoding. The PEP states just that "the server *may* # use chunked encoding" to send each piece of data we give it, # if we don't specify a Content-Length header and if both the # client and the server support it. According to the HTTP spec. # all (and only) HTTP/1.1 compliant clients have to support it. # We'll assume that the server software supports it too, and # actually uses it (gevent does!) even if we have no way to # check it. We cannot try to force such behavior as the PEP # doesn't even allow us to set the Transfer-Encoding header. # The second abuse is the use of the write() callable, returned # by start_response, even if the PEP strongly discourages its # use in new applications. We do it because we need a way to # detect when the client disconnects, and we hope to achieve # this by seeing when a call to write() fails, i.e. raises an # exception. This behavior isn't documented by the PEP, but it # seems reasonable and it's present in gevent (which raises a # socket.error). # The third non-standard behavior that we expect (related to # the previous one) is that no one in the application-to-client # chain does response buffering: neither any middleware nor the # server (gevent doesn't!). This should also hold outside the # server realm (i.e. no proxy buffering) but that's definitely # not our responsibility. # The fourth "hack" is to avoid an error to be printed on the # logs. If the client terminates the connection, we catch and # silently ignore the exception and return gracefully making # the server try to write the last zero-sized chunk (used to # mark the end of the stream). This will fail and produce an # error. To avoid this we detect if we're running on a gevent # server and make it "forget" this was a chunked response. # Check if the client will understand what we will produce. if request.accept_mimetypes.quality("text/event-stream") <= 0: return NotAcceptable()(environ, start_response) # Initialize the response and get the write() callback. The # Cache-Control header is useless for conforming clients, as # the spec. already imposes that behavior on them, but we set # it explicitly to avoid unwanted caching by unaware proxies and # middlewares. write = start_response( text_to_native_str("200 OK"), [(text_to_native_str("Content-Type"), text_to_native_str("text/event-stream; charset=utf-8")), (text_to_native_str("Cache-Control"), text_to_native_str("no-cache"))]) # This is a part of the fourth hack (see above). if hasattr(start_response, "__self__") and \ isinstance(start_response.__self__, WSGIHandler): handler = start_response.__self__ else: handler = None # One-shot means that we will terminate the request after the # first batch of sent events. We do this when we believe the # client doesn't support chunked transfer. As this encoding has # been introduced in HTTP/1.1 (as mandatory!) we restrict to # requests in that HTTP version. Also, if it comes from an # XMLHttpRequest it has been probably sent from a polyfill (not # from the native browser implementation) which will be able to # read the response body only when it has been fully received. if environ["SERVER_PROTOCOL"] != "HTTP/1.1" or request.is_xhr: one_shot = True else: one_shot = False # As for the Server-Sent Events [1] spec., this is the way for # the client to tell us the ID of the last event it received # and to ask us to send it the ones that happened since then. # [1] http://www.w3.org/TR/eventsource/ # The spec. requires implementations to retry the connection # when it fails, adding the "Last-Event-ID" HTTP header. But in # case of an error they stop, and we have to (manually) delete # the EventSource and create a new one. To obtain that behavior # again we give the "last_event_id" as a URL query parameter # (with lower priority, to have the header override it). last_event_id = request.headers.get("Last-Event-ID") if last_event_id is None: last_event_id = request.args.get("last_event_id") # We subscribe to the publisher to receive events. sub = self._pub.get_subscriber(last_event_id) # Send some data down the pipe. We need that to make the user # agent announces the connection (see the spec.). Since it's a # comment it will be ignored. write(b":\n") # XXX We could make the client change its reconnection timeout # by sending a "retry:" line. # As a last line of defence from very bad-behaving servers we # don't want to the request to last longer than _GLOBAL_TIMEOUT # seconds (see above). We use "False" to just cause the control # exit the with block, instead of raising an exception. with Timeout(self._GLOBAL_TIMEOUT, False): # Repeat indefinitely. while True: # Proxies often have a read timeout. We try not to hit # it by not being idle for more than _PING_TIMEOUT # seconds, sending a ping (i.e. a comment) if there's # no real data. try: with Timeout(self._PING_TIMEOUT): data = b"".join(sub.get()) got_sth = True except Timeout: data = b":\n" got_sth = False try: with Timeout(self._WRITE_TIMEOUT): write(data) # The PEP doesn't tell what has to happen when a write # fails. We're conservative, and allow any unexpected # event to interrupt the request. We hope it's enough # to detect when the client disconnects. It is with # gevent, which raises a socket.error. The timeout (we # catch that too) is just an extra precaution. except Exception: # This is part of the fourth hack (see above). if handler is not None: handler.response_use_chunked = False break # If we decided this is one-shot, stop the long-poll as # soon as we sent the client some real data. if one_shot and got_sth: break # An empty iterable tells the server not to send anything. return []