""" A generic websocket client wrapped in an Actor Requires thespian (of course) and websocket packages. Linux-specific due to use of epoll(); can be changed to use select() fairly easily, but performance will suffer """ from __future__ import absolute_import, division, print_function import select import logging as log from datetime import timedelta from collections import namedtuple import websocket from websocket import ABNF from thespian.actors import ActorExitRequest, WakeupMessage, Actor # Message to send to open the connection Start_Websocket = namedtuple('Start_Websocket', 'ws_addr start_msg upstream') # Message type that's sent to the 'upstream' Websocket_Output = namedtuple('Websocket_Output', 'msg') # Message to send to send more data out the websocket Websocket_Input = namedtuple('Websocket_Input', 'msg') # Maximum number of messages to read per wakeup ; raise this if you see # a lot of "WebsocketClientActor not keeping up with incoming websocket data" # messages in the log output MAX_MSGS_PER_READ = 50 class WebsocketClientActor(Actor): """ A websocket client wrapped in an Actor This was originally written to support fetching streaming data via a websocket; the Websocket_Input bits are less stress-tested. Usage: start_msg = "subscribe" ws_addr = "wss://ws-feed.somesite.com" startmsg = Start_Websocket(ws_addr, start_msg, receipient_Actor) self.client = self.createActor(WebsocketClientActor) self.send(self.client, startmsg) ...and recipient_Actor will start receiveing Websocket_Output messages """ def __init__(self): super(WebsocketClientActor, self).__init__() self.started = False self.running = False self.ws = None def check_websocket(self): msgs = 0 events = self.epoll.poll(0) while events and msgs < MAX_MSGS_PER_READ: for fileno, event in events: if not (event & select.EPOLLIN): self.send(self.myAddress, ActorExitRequest()) op_code, frame = self.ws.recv_data_frame(True) if op_code == ABNF.OPCODE_CLOSE: self.send(self.myAddress, ActorExitRequest()) elif op_code in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG, ABNF.OPCODE_CONT): pass # ignore else: msgs += 1 self.send(self.config.upstream, Websocket_Output(frame.data)) events = self.epoll.poll(0) if msgs >= MAX_MSGS_PER_READ: log.critical("WebsocketClientActor not keeping up with incoming websocket data") def receiveMsg_Start_Websocket(self, m, sender): if self.started: # already started return self.config = m self.started = True self.running = True # open the connection websocket.enableTrace(False) self.ws = websocket.create_connection(m.ws_addr) log.info("Websocket Connected") # set up the socket monitoring self.epoll = select.epoll() mask = select.EPOLLIN | select.EPOLLHUP | select.EPOLLERR self.epoll.register(self.ws.sock.fileno(), mask) # subscribe to the feed self.ws.send(m.start_msg) # start checking for data self.send(self.myAddress, WakeupMessage(None)) def receiveMsg_Websocket_Input(self, m, sender): if not self.running: # can't send return log.debug("Websocket sending %r", m.msg) self.ws.send(m.msg) def receiveMsg_WakeupMessage(self, m, sender): if not self.running: # stopped return try: self.check_websocket() except Exception as e: log.error("Got exception: %r", e) self.send(self.myAddress, ActorExitRequest()) raise self.wakeupAfter(timedelta(milliseconds=20)) def receiveMsg_ActorExitRequest(self, m, sender): """Stop the Websocket, and the actor""" log.info("Websocket exiting") self.running = False self.epoll.close() self.ws.close() def receiveMessage(self, m, sender): handler = { WakeupMessage: self.receiveMsg_WakeupMessage, Start_Websocket: self.receiveMsg_Start_Websocket, Websocket_Input: self.receiveMsg_Websocket_Input, ActorExitRequest: self.receiveMsg_ActorExitRequest }.get(type(m), None) if handler is None: log.error("Unhandled message %r from %r", m, sender) return handler(m, sender)