Skip to content

Commit 7893f59

Browse files
committed
fix(sdk): mirror eviction connect/reconnect loop in async monitor
Addresses review on #824: AsyncEvictionMonitor still had the pre-fix single-shot _run, so AsyncDevbox.on_evict would silently never fire. Port the sync monitor's fixes to sdk/async_eviction.py: - request the SSE Accept header (text/event-stream) on watch_evictions - reconnect with capped backoff until no devbox is still interested Also ruff-format sdk/eviction.py (the sync fix wrapped a call that fits on one line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c07ad4f commit 7893f59

2 files changed

Lines changed: 49 additions & 15 deletions

File tree

src/runloop_api_client/sdk/async_eviction.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,57 @@ async def close(self) -> None:
5555
self._callbacks.clear()
5656
await self._close_stream()
5757

58+
# Reconnect backoff bounds (seconds). The server force-closes the stream on purpose — on a
59+
# leader change (FAILED_PRECONDITION) or a slow consumer (RESOURCE_EXHAUSTED) — and expects the
60+
# client to reconnect and re-read the snapshot, which re-delivers anything missed. So a single
61+
# stream ending is normal, not terminal: reconnect until no devbox is still interested.
62+
_RECONNECT_BACKOFF_INITIAL_S = 0.5
63+
_RECONNECT_BACKOFF_MAX_S = 30.0
64+
5865
async def _run(self) -> None:
66+
backoff = self._RECONNECT_BACKOFF_INITIAL_S
5967
try:
60-
stream = await self._client.devboxes.watch_evictions()
61-
async with self._lock:
62-
self._stream = stream
63-
async with stream:
64-
async for event in stream:
65-
await self._dispatch(event)
68+
while True:
69+
async with self._lock:
70+
if not self._callbacks:
71+
return
72+
try:
73+
# Force the SSE Accept header: the endpoint only streams for
74+
# text/event-stream; the generated client's default (application/json) gets an
75+
# empty text/plain response, so the feed would silently deliver nothing.
76+
stream = await self._client.devboxes.watch_evictions(extra_headers={"Accept": "text/event-stream"})
6677
async with self._lock:
67-
if not self._callbacks:
68-
break
69-
except asyncio.CancelledError:
70-
raise
71-
except Exception:
72-
_logger.exception("async eviction monitor stream failed")
78+
self._stream = stream
79+
_logger.debug("async eviction monitor stream connected")
80+
async with stream:
81+
async for event in stream:
82+
_logger.debug("async eviction monitor received event for %s", event.devbox_id)
83+
await self._dispatch(event)
84+
async with self._lock:
85+
if not self._callbacks:
86+
return
87+
# Clean end (server closed the stream): reset backoff and reconnect if still
88+
# interested. The reconnect's snapshot re-delivers still-pending evictions.
89+
backoff = self._RECONNECT_BACKOFF_INITIAL_S
90+
_logger.debug("async eviction monitor stream ended; reconnecting")
91+
except asyncio.CancelledError:
92+
raise
93+
except Exception:
94+
# An intentional teardown (close/unregister clears the interest set, then closes
95+
# the stream) surfaces here as a read error — exit quietly in that case.
96+
async with self._lock:
97+
interested = bool(self._callbacks)
98+
if not interested:
99+
return
100+
# Routine: the server force-closes on leader change / slow consumer, and a
101+
# long-lived stream can drop (e.g. an HTTP/2 disconnect). Reconnecting recovers
102+
# it, so keep this at debug to avoid log spam.
103+
_logger.debug("async eviction monitor stream error; reconnecting", exc_info=True)
104+
async with self._lock:
105+
if not self._callbacks:
106+
return
107+
await asyncio.sleep(backoff)
108+
backoff = min(backoff * 2, self._RECONNECT_BACKOFF_MAX_S)
73109
finally:
74110
async with self._lock:
75111
self._stream = None

src/runloop_api_client/sdk/eviction.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,7 @@ def _run(self) -> None:
9797
# Force the SSE Accept header: the endpoint only streams for
9898
# text/event-stream; the generated client's default (application/json) gets an
9999
# empty text/plain response, so the feed would silently deliver nothing.
100-
stream = self._client.devboxes.watch_evictions(
101-
extra_headers={"Accept": "text/event-stream"}
102-
)
100+
stream = self._client.devboxes.watch_evictions(extra_headers={"Accept": "text/event-stream"})
103101
with self._lock:
104102
self._stream = stream
105103
_logger.debug("eviction monitor stream connected")

0 commit comments

Comments
 (0)