Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/rivian/ws_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

RATE_LIMITED = "Rate limited"
UNAUTHENTICATED = "Unauthenticated"
# Close code with which the server refuses a connection
FORBIDDEN = 4403
RATE_LIMIT_BACKOFF_MIN = 30
RATE_LIMIT_BACKOFF_MAX = 900

Expand Down Expand Up @@ -85,6 +87,8 @@ def __init__(
self._backoff_reason: str | None = None
# The server's reason for closing the current connection, if it gave one
self._close_message: str | None = None
# Whether the server refused the current connection (4403) before its ack
self._forbidden = False
self._backoff_state = (
_Backoff()
if backoff_key is None
Expand Down Expand Up @@ -145,8 +149,12 @@ def rate_limited(self) -> bool:

@property
def unauthenticated(self) -> bool:
"""Return `True` if the server closed the latest connection as unauthenticated."""
return self._close_message == UNAUTHENTICATED
"""Return `True` if the server closed the latest connection as unauthenticated.

That is, with the reason "Unauthenticated" or as forbidden (4403) before
the connection was acknowledged.
"""
return self._close_message == UNAUTHENTICATED or self._forbidden

@property
def backoff_remaining(self) -> float:
Expand All @@ -164,6 +172,7 @@ async def new_connection(self, start_monitor: bool = False) -> None:
self._disconnect = False
self._backoff_reason = None
self._close_message = None
self._forbidden = False
self._connection_ack.clear()
# pylint: disable=protected-access
assert self._account._session
Expand Down Expand Up @@ -270,7 +279,11 @@ async def _receive(self, websocket: ClientWebSocketResponse[bool]) -> None:
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
self._log_message(msg)
self._close_message = msg.extra or None
if msg.extra == UNAUTHENTICATED:
# After the ack a 4403 may be transient, so reconnect instead
self._forbidden = (
not acknowledged and websocket.close_code == FORBIDDEN
)
if self.unauthenticated:
self._disconnect = True
elif msg.extra == RATE_LIMITED:
self._start_backoff(BACKOFF_RATE_LIMITED)
Expand Down
39 changes: 38 additions & 1 deletion tests/test_ws_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class FakeServer:
def __init__(self) -> None:
"""Initialize the fake server."""
self.close_reasons: list[str] = []
self.close_code = 4000
self.ack_before_close = False
self.silent_connections = 0
self.ping_connections = 0
self.ping_once_connections = 0
Expand Down Expand Up @@ -58,7 +60,11 @@ async def handler(self, request: web.Request) -> web.WebSocketResponse:
await ws.send_json({"type": "ping"})
await ws.receive()
elif self.close_reasons:
await ws.close(code=4000, message=self.close_reasons.pop(0).encode())
if self.ack_before_close:
await ws.send_json({"type": "connection_ack"})
await ws.close(
code=self.close_code, message=self.close_reasons.pop(0).encode()
)
else:
await ws.send_json({"type": "connection_ack"})
# Take subscribe requests until the client closes
Expand Down Expand Up @@ -168,13 +174,44 @@ async def test_unauthenticated_stops_monitor(
) -> None:
"""Test the monitor stops reconnecting when unauthenticated."""
server.close_reasons = ["Unauthenticated"]
add_subscription(monitor)
await monitor.new_connection(start_monitor=True)
assert monitor.monitor
await wait_for(monitor.monitor.done)
assert server.connections == 1
assert not monitor.connected


async def test_forbidden_stops_monitor(
server: FakeServer, monitor: WebSocketMonitor, rivian: Rivian
) -> None:
"""Test the monitor stops reconnecting when the server closes as forbidden."""
server.close_code = 4403
server.close_reasons = ["Forbidden", "Forbidden"]
add_subscription(monitor)
await monitor.new_connection(start_monitor=True)
assert monitor.monitor
await wait_for(monitor.monitor.done)
assert server.connections == 1
assert not monitor.connected
assert rivian.ws_unauthenticated


async def test_forbidden_after_ack_reconnects(
server: FakeServer, monitor: WebSocketMonitor, rivian: Rivian
) -> None:
"""Test the monitor reconnects when an acknowledged connection closes as forbidden."""
server.close_code = 4403
server.ack_before_close = True
server.close_reasons = ["Forbidden"]
add_subscription(monitor)
await monitor.new_connection(start_monitor=True)
await wait_for(lambda: server.connections == 2 and monitor.connection_ack.is_set())
assert monitor.connected
assert monitor.monitor and not monitor.monitor.done()
assert not rivian.ws_unauthenticated


async def test_rate_limited_backs_off_and_reconnects(
server: FakeServer, monitor: WebSocketMonitor, backoff: BackoffSleeps
) -> None:
Expand Down
Loading