Module server.servercontext
Manages a group of connections using RabbitMQ as the transport.
Classes
class ConnectionAttemptFailed (*args, **kwargs)-
Expand source code
class ConnectionAttemptFailed(ConnectionError): passConnection error.
Ancestors
- builtins.ConnectionError
- builtins.OSError
- builtins.Exception
- builtins.BaseException
class ServerContext (name: str,
connection_factory: Callable[[], LobbyConnection],
services: Iterable[Service])-
Expand source code
@with_logger class ServerContext: """ Base class for managing connections and holding state about them using RabbitMQ. """ _logger: ClassVar[logging.Logger] def __init__( self, name: str, connection_factory: Callable[[], LobbyConnection], services: Iterable[Service], ): super().__init__() self.name = name self._drain_event: Optional[asyncio.Event] = None self._connection_factory = connection_factory self._services = list(services) self._connection: Optional[AbstractConnection] = None self._channel: Optional[AbstractChannel] = None self._exchanges: dict[str, AbstractExchange] = {} self._exchange_types: dict[str, ExchangeType] = {} self.connections: dict[LobbyConnection, RabbitMQProtocol] = {} self.sessions: dict[int, tuple[LobbyConnection, RabbitMQProtocol]] = {} self.sessions_expiration: dict[int, datetime] = {} self._consumer_tags: list[tuple[AbstractQueue, str]] = [] self._tasks: set[asyncio.Task] = set() self._session_cleanup_timer: Optional[LazyIntervalTimer] = None self.connection_exchange: Optional[str] = None self.auth_exchange: Optional[str] = None def __repr__(self): return f"ServerContext({self.name})" async def listen( self, client_routing_keys: list[str], ): self._logger.debug( "%s: listen", self.name, ) await self._connect() await self._declare_exchange(config.INBOUND_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.SESSION_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.OUTBOUND_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.SESSION_REQUEST_EXCHANGE_NAME, ExchangeType.TOPIC) hostname = socket.gethostname() # Declare and consume state messages (already authenticated player connected) session_queue_name = f"{config.SESSION_EXCHANGE_NAME}.state.{hostname}" session_consumer = await self.declare_queue_and_consume( exchange_name=config.SESSION_EXCHANGE_NAME, routing_keys=["#"], callback=self._on_session_message, queue_name=session_queue_name, ) if session_consumer is not None: self._consumer_tags.append(session_consumer) # Declare and consume client messages client_queue_name = f"{config.INBOUND_EXCHANGE_NAME}.client.{hostname}" client_consumer = await self.declare_queue_and_consume( exchange_name=config.INBOUND_EXCHANGE_NAME, routing_keys=client_routing_keys, callback=self._on_client_message, queue_name=client_queue_name, ) if client_consumer is not None: self._consumer_tags.append(client_consumer) await self.publish(config.SESSION_REQUEST_EXCHANGE_NAME, "", {}, reply_to=session_queue_name) self._logger.info( "%s: listening on RabbitMQ exchanges: connection=%s, state=%s", self.name, config.INBOUND_EXCHANGE_NAME, config.SESSION_EXCHANGE_NAME, ) if config.CLEAN_EXPIRED_SESSIONS: self._session_cleanup_timer = LazyIntervalTimer( lambda: config.SESSION_CHECK_INTERVAL, self._cleanup_sessions, start=True ) async def _connect(self) -> None: try: self._connection = await aio_pika.connect_robust( "amqp://{user}:{password}@{server}:{port}/{vhost}".format( user=config.MQ_USER, password=config.MQ_PASSWORD, vhost=config.MQ_VHOST, server=config.MQ_SERVER, port=config.MQ_PORT, ), loop=asyncio.get_running_loop(), ) except ProbableAuthenticationError as e: self._logger.warning( "Unable to connect to RabbitMQ. Incorrect credentials?", exc_info=True ) raise ConnectionAttemptFailed from e except ConnectionError as e: self._logger.warning( "Unable to connect to RabbitMQ. Is it running?", exc_info=True ) raise ConnectionAttemptFailed from e except Exception as e: self._logger.warning( "Unable to connect to RabbitMQ due to unhandled excpetion %s. Incorrect vhost?", e, exc_info=True, ) raise ConnectionAttemptFailed from e assert self._connection is not None self._channel = await self._connection.channel(publisher_confirms=True) self._logger.debug("Connected to RabbitMQ %r", self._connection) async def declare_exchange( self, exchange_name: str, exchange_type: ExchangeType = ExchangeType.TOPIC, durable: bool = True ) -> None: await self._declare_exchange(exchange_name, exchange_type, durable) async def _declare_exchange( self, exchange_name: str, exchange_type: ExchangeType, durable: bool = True ) -> None: assert self._channel is not None new_exchange = await self._channel.declare_exchange( exchange_name, exchange_type, durable=durable, ) self._exchanges[exchange_name] = new_exchange self._exchange_types[exchange_name] = exchange_type async def publish( self, exchange_name: str, routing: str, payload: dict, mandatory: bool = False, delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT, reply_to: Optional[str] = None, ) -> None: await self.publish_many( exchange_name, routing, [payload], mandatory=mandatory, delivery_mode=delivery_mode, reply_to=reply_to, ) async def publish_many( self, exchange_name: str, routing: str, payloads: Iterable[dict], mandatory: bool = False, delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT, reply_to: Optional[str] = None, ) -> None: assert self._channel is not None exchange = self._exchanges.get(exchange_name) if exchange is None: raise KeyError(f"Unknown exchange {exchange_name}.") for payload in payloads: message = aio_pika.Message( json.dumps(payload).encode(), delivery_mode=delivery_mode, content_type="application/json", reply_to=reply_to ) await exchange.publish( message, routing_key=routing, mandatory=mandatory ) self._logger.log( TRACE, "Published message %s to %s/%s", payload, exchange_name, routing ) async def declare_queue_and_consume( self, exchange_name: str, routing_keys: list[str], callback: Callable[[AbstractIncomingMessage], Awaitable[None]], queue_name: str = "", exclusive: bool = True, auto_delete: bool = True, durable: bool = False, ) -> Optional[tuple[AbstractQueue, str]]: """ Declare a queue, bind it to an exchange with the given routing key, and start consuming. Returns `(queue, consumer_tag)` so the caller can cancel on shutdown. """ assert self._channel is not None exchange = self._exchanges.get(exchange_name) if exchange is None: raise KeyError(f"Unknown exchange {exchange_name}.") queue = await self._channel.declare_queue( queue_name, exclusive=exclusive, auto_delete=auto_delete, durable=durable, ) for routing_key in routing_keys: await queue.bind(exchange, routing_key=routing_key) consumer_tag = await queue.consume(callback) self._logger.debug( "Consuming from queue %r bound to %s/%s", queue.name, exchange_name, routing_keys, ) return queue, consumer_tag async def _on_session_message(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): try: payload = json.loads(message.body) except (ValueError, UnicodeDecodeError): self._logger.warning("Dropping session message with non-JSON body") return self._logger.log(TRACE, "Received session message: %s", payload) if not isinstance(payload, dict): self._logger.warning("Dropping session message: payload is not a JSON object") return player_id = payload.get("player_id") if player_id is None: self._logger.warning("Dropping session message: missing player_id") return try: player_id = int(player_id) except (TypeError, ValueError): self._logger.warning("Dropping session message: invalid player_id %r", player_id) return session_id = payload.get("session_id") if session_id is None: self._logger.warning("Dropping session message: missing session_id") return try: session_id = int(session_id) except (TypeError, ValueError): self._logger.warning("Dropping session message: invalid session_id %r", session_id) return state = payload.get("state") if not isinstance(state, dict): self._logger.warning("Dropping session message: missing state") return state_type = state.get("@type") ip_address = payload.get("ip_address") or "unknown" if state_type == "new": self.sessions_expiration[session_id] = datetime_now() + timedelta(seconds=int(state.get("seconds_to_expiry", config.DEFAULT_SESSION_EXPIRATION))) if session_id not in self.sessions: self._create_session(ip_address, player_id, session_id, config.TRACK_PLAYER_STATE) elif state_type == "existing": self.sessions_expiration[session_id] = datetime_now() + timedelta(seconds=int(state.get("seconds_to_expiry", config.DEFAULT_SESSION_EXPIRATION))) if session_id not in self.sessions: self._create_session(ip_address, player_id, session_id, False) elif state_type == "gone": await self._remove_session(session_id) async def _remove_session(self, session_id: int): try: session_key = int(session_id) except (TypeError, ValueError): self._logger.warning( "%s: Dropping 'gone' session message: invalid session_id %r", self.name, session_id, ) return self.sessions_expiration.pop(session_key, None) conn_tuple = self.sessions.pop(session_key, None) if conn_tuple is not None: conn, proto = conn_tuple self._logger.info( "%s: Closing connection for session %s (gone)", self.name, conn.session, ) await proto.close() self.connections.pop(conn, None) else: self._logger.debug( "%s: No active connection found to close for session %s", self.name, session_id, ) def _create_session(self, ip_address: str, player_id: int, session_id: int, publish_welcome_messages: bool): peername = Address(ip_address, 0) connection = self._connection_factory() if session_id is not None: try: connection.session = int(session_id) except (TypeError, ValueError): pass queue: asyncio.Queue = asyncio.Queue() protocol = RabbitMQProtocol( queue=queue, server_context=self, session=connection.session, ) self.connections[connection] = protocol self.sessions[connection.session] = (connection, protocol) self._logger.info( "%s: Authenticated player %s connected with session %s", self.name, player_id, connection.session, ) task = asyncio.create_task( self.handle_client_connected(connection, protocol, peername, player_id, publish_welcome_messages) ) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def _cleanup_sessions(self): now = datetime_now() expired_session_ids = [session_id for session_id, expiration in self.sessions_expiration.items() if expiration < now] for session_id in expired_session_ids: self._logger.info("Dropping expired session %s", session_id) await self._remove_session(session_id) async def _on_client_message(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): try: payload = json.loads(message.body) except (ValueError, UnicodeDecodeError): self._logger.warning("Dropping connection message with non-JSON body") return self._logger.log(TRACE, "Received client message: %s", payload) if not isinstance(payload, dict): self._logger.warning("Dropping connection message: payload is not a JSON object") return session_id = payload.get("session_id") if session_id is None and isinstance(payload.get("target"), dict): session_id = payload["target"].get("session_id") or payload["target"].get("session") if session_id is None: self._logger.warning("Dropping connection message: missing session_id") return try: session_id = int(session_id) except (TypeError, ValueError): self._logger.warning("Dropping connection message: invalid session_id %r", session_id) return conn_tuple = self.sessions.get(session_id) if conn_tuple is None: self._logger.debug("Dropping connection message: session %s not found on this context", session_id) return conn, proto = conn_tuple inner_message = payload.get("message", payload) if not isinstance(inner_message, dict): inner_message = payload await proto.queue.put(inner_message) async def shutdown(self, timeout: Optional[float] = 5): self._logger.debug("%s: stop serving", self.name) # Cancel consumers for queue, consumer_tag in self._consumer_tags: try: await queue.cancel(consumer_tag) except Exception: pass self._consumer_tags.clear() async def close_or_abort(conn, proto): try: await asyncio.wait_for(proto.close(), timeout) except asyncio.TimeoutError: proto.abort() self._logger.warning( "%s: Protocol did not terminate cleanly for '%s'", self.name, conn.get_user_identifier() ) if timeout is None: self._logger.debug( "%s: Waiting for connections to close", self.name, ) else: self._logger.debug( "%s: Waiting up to %s for connections to close", self.name, humanize.naturaldelta(timeout) ) for fut in asyncio.as_completed([ close_or_abort(conn, proto) for conn, proto in list(self.connections.items()) ]): await fut for task in list(self._tasks): task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) if self._channel is not None: await self._channel.close() self._channel = None if self._connection is not None: await self._connection.close() self._connection = None self._logger.debug("%s: All connections closed", self.name) if self._session_cleanup_timer is not None: self._session_cleanup_timer.stop() async def drain_connections(self): """ Wait for all connections to terminate. """ if not self.connections: return if not self._drain_event: self._drain_event = asyncio.Event() await self._drain_event.wait() def write_broadcast(self, message: dict, validate_fn=lambda _: True): for conn, proto in list(self.connections.items()): try: if proto.is_connected() and validate_fn(conn): proto.write_message(message) except Exception: self._logger.exception( "%s: Encountered error in broadcast: %s", self.name, conn ) def write_broadcast_raw(self, data: bytes, validate_fn=lambda _: True): for conn, proto in list(self.connections.items()): try: if proto.is_connected() and validate_fn(conn): proto.write_raw(data) except Exception: self._logger.exception( "%s: Encountered error in broadcast: %s", self.name, conn ) async def handle_client_connected( self, connection: LobbyConnection, protocol: RabbitMQProtocol, peername: Address, player_id: int, publish_welcome_messages: bool, ): try: await connection.on_connection_made(protocol, peername) await connection.on_external_auth(player_id, "broker", publish_welcome_messages) metrics.user_connections.labels("None", "None").inc() while protocol.is_connected(): message = await protocol.read_message() with metrics.connection_on_message_received.time(): await connection.on_message_received(message) except ( ConnectionError, DisconnectedError, TimeoutError, asyncio.CancelledError, ): pass except UnicodeDecodeError as e: self._logger.exception( "%s: Unicode error in protocol for '%s': %s '...%s...'", self.name, connection.get_user_identifier(), e, e.object[e.start-20:e.end+20] ) except Exception as e: self._logger.exception( "%s: Exception in protocol for '%s': %s", self.name, connection.get_user_identifier(), e ) finally: self.connections.pop(connection, None) self.sessions.pop(connection.session, None) # Do not wait for buffers to empty here. This could stop the process # from exiting if the client isn't reading data. protocol.abort() for service in self._services: with self.suppress_and_log(service.on_connection_lost, Exception): service.on_connection_lost(connection) with self.suppress_and_log(connection.on_connection_lost, Exception): await connection.on_connection_lost() self._logger.info( "%s: Client disconnected for '%s'", self.name, connection.get_user_identifier() ) if ( self._drain_event is not None and not self._drain_event.is_set() and not self.connections ): self._drain_event.set() metrics.user_connections.labels( connection.user_agent, connection.version ).dec() @contextmanager def suppress_and_log(self, func, *exceptions: type[BaseException]): try: yield except exceptions: if hasattr(func, "__self__"): desc = f"{func.__self__.__class__.__name__}.{func.__name__}" else: desc = func.__name__ self._logger.warning( "Unexpected exception in %s", desc, exc_info=True )Base class for managing connections and holding state about them using RabbitMQ.
Methods
async def declare_exchange(self,
exchange_name: str,
exchange_type: aio_pika.abc.ExchangeType = ExchangeType.TOPIC,
durable: bool = True) ‑> None-
Expand source code
async def declare_exchange( self, exchange_name: str, exchange_type: ExchangeType = ExchangeType.TOPIC, durable: bool = True ) -> None: await self._declare_exchange(exchange_name, exchange_type, durable) async def declare_queue_and_consume(self,
exchange_name: str,
routing_keys: list[str],
callback: Callable[[aio_pika.abc.AbstractIncomingMessage], Awaitable[None]],
queue_name: str = '',
exclusive: bool = True,
auto_delete: bool = True,
durable: bool = False) ‑> tuple[aio_pika.abc.AbstractQueue, str] | None-
Expand source code
async def declare_queue_and_consume( self, exchange_name: str, routing_keys: list[str], callback: Callable[[AbstractIncomingMessage], Awaitable[None]], queue_name: str = "", exclusive: bool = True, auto_delete: bool = True, durable: bool = False, ) -> Optional[tuple[AbstractQueue, str]]: """ Declare a queue, bind it to an exchange with the given routing key, and start consuming. Returns `(queue, consumer_tag)` so the caller can cancel on shutdown. """ assert self._channel is not None exchange = self._exchanges.get(exchange_name) if exchange is None: raise KeyError(f"Unknown exchange {exchange_name}.") queue = await self._channel.declare_queue( queue_name, exclusive=exclusive, auto_delete=auto_delete, durable=durable, ) for routing_key in routing_keys: await queue.bind(exchange, routing_key=routing_key) consumer_tag = await queue.consume(callback) self._logger.debug( "Consuming from queue %r bound to %s/%s", queue.name, exchange_name, routing_keys, ) return queue, consumer_tagDeclare a queue, bind it to an exchange with the given routing key, and start consuming. Returns
(queue, consumer_tag)so the caller can cancel on shutdown. async def drain_connections(self)-
Expand source code
async def drain_connections(self): """ Wait for all connections to terminate. """ if not self.connections: return if not self._drain_event: self._drain_event = asyncio.Event() await self._drain_event.wait()Wait for all connections to terminate.
async def handle_client_connected(self,
connection: LobbyConnection,
protocol: RabbitMQProtocol,
peername: Address,
player_id: int,
publish_welcome_messages: bool)-
Expand source code
async def handle_client_connected( self, connection: LobbyConnection, protocol: RabbitMQProtocol, peername: Address, player_id: int, publish_welcome_messages: bool, ): try: await connection.on_connection_made(protocol, peername) await connection.on_external_auth(player_id, "broker", publish_welcome_messages) metrics.user_connections.labels("None", "None").inc() while protocol.is_connected(): message = await protocol.read_message() with metrics.connection_on_message_received.time(): await connection.on_message_received(message) except ( ConnectionError, DisconnectedError, TimeoutError, asyncio.CancelledError, ): pass except UnicodeDecodeError as e: self._logger.exception( "%s: Unicode error in protocol for '%s': %s '...%s...'", self.name, connection.get_user_identifier(), e, e.object[e.start-20:e.end+20] ) except Exception as e: self._logger.exception( "%s: Exception in protocol for '%s': %s", self.name, connection.get_user_identifier(), e ) finally: self.connections.pop(connection, None) self.sessions.pop(connection.session, None) # Do not wait for buffers to empty here. This could stop the process # from exiting if the client isn't reading data. protocol.abort() for service in self._services: with self.suppress_and_log(service.on_connection_lost, Exception): service.on_connection_lost(connection) with self.suppress_and_log(connection.on_connection_lost, Exception): await connection.on_connection_lost() self._logger.info( "%s: Client disconnected for '%s'", self.name, connection.get_user_identifier() ) if ( self._drain_event is not None and not self._drain_event.is_set() and not self.connections ): self._drain_event.set() metrics.user_connections.labels( connection.user_agent, connection.version ).dec() async def listen(self, client_routing_keys: list[str])-
Expand source code
async def listen( self, client_routing_keys: list[str], ): self._logger.debug( "%s: listen", self.name, ) await self._connect() await self._declare_exchange(config.INBOUND_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.SESSION_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.OUTBOUND_EXCHANGE_NAME, ExchangeType.TOPIC) await self._declare_exchange(config.SESSION_REQUEST_EXCHANGE_NAME, ExchangeType.TOPIC) hostname = socket.gethostname() # Declare and consume state messages (already authenticated player connected) session_queue_name = f"{config.SESSION_EXCHANGE_NAME}.state.{hostname}" session_consumer = await self.declare_queue_and_consume( exchange_name=config.SESSION_EXCHANGE_NAME, routing_keys=["#"], callback=self._on_session_message, queue_name=session_queue_name, ) if session_consumer is not None: self._consumer_tags.append(session_consumer) # Declare and consume client messages client_queue_name = f"{config.INBOUND_EXCHANGE_NAME}.client.{hostname}" client_consumer = await self.declare_queue_and_consume( exchange_name=config.INBOUND_EXCHANGE_NAME, routing_keys=client_routing_keys, callback=self._on_client_message, queue_name=client_queue_name, ) if client_consumer is not None: self._consumer_tags.append(client_consumer) await self.publish(config.SESSION_REQUEST_EXCHANGE_NAME, "", {}, reply_to=session_queue_name) self._logger.info( "%s: listening on RabbitMQ exchanges: connection=%s, state=%s", self.name, config.INBOUND_EXCHANGE_NAME, config.SESSION_EXCHANGE_NAME, ) if config.CLEAN_EXPIRED_SESSIONS: self._session_cleanup_timer = LazyIntervalTimer( lambda: config.SESSION_CHECK_INTERVAL, self._cleanup_sessions, start=True ) async def publish(self,
exchange_name: str,
routing: str,
payload: dict,
mandatory: bool = False,
delivery_mode: aio_pika.abc.DeliveryMode = 2,
reply_to: str | None = None) ‑> None-
Expand source code
async def publish( self, exchange_name: str, routing: str, payload: dict, mandatory: bool = False, delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT, reply_to: Optional[str] = None, ) -> None: await self.publish_many( exchange_name, routing, [payload], mandatory=mandatory, delivery_mode=delivery_mode, reply_to=reply_to, ) async def publish_many(self,
exchange_name: str,
routing: str,
payloads: Iterable[dict],
mandatory: bool = False,
delivery_mode: aio_pika.abc.DeliveryMode = 2,
reply_to: str | None = None) ‑> None-
Expand source code
async def publish_many( self, exchange_name: str, routing: str, payloads: Iterable[dict], mandatory: bool = False, delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT, reply_to: Optional[str] = None, ) -> None: assert self._channel is not None exchange = self._exchanges.get(exchange_name) if exchange is None: raise KeyError(f"Unknown exchange {exchange_name}.") for payload in payloads: message = aio_pika.Message( json.dumps(payload).encode(), delivery_mode=delivery_mode, content_type="application/json", reply_to=reply_to ) await exchange.publish( message, routing_key=routing, mandatory=mandatory ) self._logger.log( TRACE, "Published message %s to %s/%s", payload, exchange_name, routing ) async def shutdown(self, timeout: float | None = 5)-
Expand source code
async def shutdown(self, timeout: Optional[float] = 5): self._logger.debug("%s: stop serving", self.name) # Cancel consumers for queue, consumer_tag in self._consumer_tags: try: await queue.cancel(consumer_tag) except Exception: pass self._consumer_tags.clear() async def close_or_abort(conn, proto): try: await asyncio.wait_for(proto.close(), timeout) except asyncio.TimeoutError: proto.abort() self._logger.warning( "%s: Protocol did not terminate cleanly for '%s'", self.name, conn.get_user_identifier() ) if timeout is None: self._logger.debug( "%s: Waiting for connections to close", self.name, ) else: self._logger.debug( "%s: Waiting up to %s for connections to close", self.name, humanize.naturaldelta(timeout) ) for fut in asyncio.as_completed([ close_or_abort(conn, proto) for conn, proto in list(self.connections.items()) ]): await fut for task in list(self._tasks): task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) if self._channel is not None: await self._channel.close() self._channel = None if self._connection is not None: await self._connection.close() self._connection = None self._logger.debug("%s: All connections closed", self.name) if self._session_cleanup_timer is not None: self._session_cleanup_timer.stop() def suppress_and_log(self, func, *exceptions: type[BaseException])-
Expand source code
@contextmanager def suppress_and_log(self, func, *exceptions: type[BaseException]): try: yield except exceptions: if hasattr(func, "__self__"): desc = f"{func.__self__.__class__.__name__}.{func.__name__}" else: desc = func.__name__ self._logger.warning( "Unexpected exception in %s", desc, exc_info=True ) def write_broadcast(self, message: dict, validate_fn=<function ServerContext.<lambda>>)-
Expand source code
def write_broadcast(self, message: dict, validate_fn=lambda _: True): for conn, proto in list(self.connections.items()): try: if proto.is_connected() and validate_fn(conn): proto.write_message(message) except Exception: self._logger.exception( "%s: Encountered error in broadcast: %s", self.name, conn ) def write_broadcast_raw(self, data: bytes, validate_fn=<function ServerContext.<lambda>>)-
Expand source code
def write_broadcast_raw(self, data: bytes, validate_fn=lambda _: True): for conn, proto in list(self.connections.items()): try: if proto.is_connected() and validate_fn(conn): proto.write_raw(data) except Exception: self._logger.exception( "%s: Encountered error in broadcast: %s", self.name, conn )