Skip to content

Commit c07ad4f

Browse files
committed
fix(sdk): eviction monitor requests text/event-stream + reconnects
Two bugs made devbox.on_evict never fire: 1. The generated watch_evictions() sends Accept: application/json, but the endpoint only streams for Accept: text/event-stream — otherwise it returns an empty text/plain 200. The monitor now forces the SSE Accept header. 2. The monitor ran the stream once and stopped. The server force-closes on leader change / slow consumer (and a long-lived HTTP/2 stream can drop), and expects the client to reconnect and re-read the snapshot. The monitor now reconnects with backoff until no devbox is still interested. Adds debug logging around connect / event / reconnect. Verified against dev: a forced flex drain now delivers on_evict for 20/20 devboxes before suspend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a6e940 commit c07ad4f

1 file changed

Lines changed: 49 additions & 10 deletions

File tree

src/runloop_api_client/sdk/eviction.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import time
1920
import logging
2021
import threading
2122
from typing import TYPE_CHECKING, Dict, Tuple, Callable, Optional
@@ -78,19 +79,57 @@ def close(self) -> None:
7879
self._callbacks.clear()
7980
self._close_stream()
8081

82+
# Reconnect backoff bounds (seconds). The server force-closes the stream on purpose — on a
83+
# leader change (FAILED_PRECONDITION) or a slow consumer (RESOURCE_EXHAUSTED) — and expects the
84+
# client to reconnect and re-read the snapshot, which re-delivers anything missed. So a single
85+
# stream ending is normal, not terminal: reconnect until no devbox is still interested.
86+
_RECONNECT_BACKOFF_INITIAL_S = 0.5
87+
_RECONNECT_BACKOFF_MAX_S = 30.0
88+
8189
def _run(self) -> None:
90+
backoff = self._RECONNECT_BACKOFF_INITIAL_S
8291
try:
83-
stream = self._client.devboxes.watch_evictions()
84-
with self._lock:
85-
self._stream = stream
86-
with stream:
87-
for event in stream:
88-
self._dispatch(event)
92+
while True:
93+
with self._lock:
94+
if not self._callbacks:
95+
return
96+
try:
97+
# Force the SSE Accept header: the endpoint only streams for
98+
# text/event-stream; the generated client's default (application/json) gets an
99+
# 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+
)
89103
with self._lock:
90-
if not self._callbacks:
91-
break
92-
except Exception:
93-
_logger.exception("eviction monitor stream failed")
104+
self._stream = stream
105+
_logger.debug("eviction monitor stream connected")
106+
with stream:
107+
for event in stream:
108+
_logger.debug("eviction monitor received event for %s", event.devbox_id)
109+
self._dispatch(event)
110+
with self._lock:
111+
if not self._callbacks:
112+
return
113+
# Clean end (server closed the stream): reset backoff and reconnect if still
114+
# interested. The reconnect's snapshot re-delivers still-pending evictions.
115+
backoff = self._RECONNECT_BACKOFF_INITIAL_S
116+
_logger.debug("eviction monitor stream ended; reconnecting")
117+
except Exception:
118+
# An intentional teardown (close/unregister clears the interest set, then closes
119+
# the stream) surfaces here as a read error — exit quietly in that case.
120+
with self._lock:
121+
interested = bool(self._callbacks)
122+
if not interested:
123+
return
124+
# Routine: the server force-closes on leader change / slow consumer, and a
125+
# long-lived stream can drop (e.g. an HTTP/2 disconnect). Reconnecting recovers
126+
# it, so keep this at debug to avoid log spam.
127+
_logger.debug("eviction monitor stream error; reconnecting", exc_info=True)
128+
with self._lock:
129+
if not self._callbacks:
130+
return
131+
time.sleep(backoff)
132+
backoff = min(backoff * 2, self._RECONNECT_BACKOFF_MAX_S)
94133
finally:
95134
with self._lock:
96135
self._stream = None

0 commit comments

Comments
 (0)