Skip to content

Commit 3fcba8e

Browse files
claudeReflex
authored andcommitted
fix(sdk): eviction monitor requests text/event-stream + reconnects
Two bugs made devbox.onEvict never fire: 1. The generated watchEvictions() 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. Mirrors the api-client-python fix, verified against dev (a forced flex drain delivers on_evict for 20/20 devboxes before suspend). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5356e81 commit 3fcba8e

1 file changed

Lines changed: 31 additions & 10 deletions

File tree

src/sdk/eviction.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,38 @@ export class EvictionMonitor {
6060
}
6161

6262
private async run(): Promise<void> {
63+
// The server force-closes the stream on purpose (leader change / slow consumer) and a
64+
// long-lived HTTP/2 stream can drop; the client is expected to reconnect and re-read the
65+
// snapshot, which re-delivers anything missed. So reconnect (with backoff) until no devbox is
66+
// still interested.
67+
const INITIAL_BACKOFF_MS = 500;
68+
const MAX_BACKOFF_MS = 30_000;
69+
let backoff = INITIAL_BACKOFF_MS;
6370
try {
64-
const stream = await this.client.devboxes.watchEvictions();
65-
this.stream = stream;
66-
for await (const event of stream) {
67-
this.dispatch(event);
68-
if (this.entries.size === 0) break;
69-
}
70-
} catch (error) {
71-
// Aborting the stream on close surfaces as an AbortError; that is expected.
72-
if (!(error instanceof Error && error.name === 'AbortError')) {
73-
console.error('Error in eviction monitor stream:', error);
71+
while (this.entries.size > 0) {
72+
try {
73+
// Force the SSE Accept header: the endpoint only streams for text/event-stream; the
74+
// generated client's default (application/json) gets an empty text/plain response, so the
75+
// feed would silently deliver nothing.
76+
const stream = await this.client.devboxes.watchEvictions({
77+
headers: { Accept: 'text/event-stream' },
78+
});
79+
this.stream = stream;
80+
for await (const event of stream) {
81+
this.dispatch(event);
82+
if (this.entries.size === 0) return;
83+
}
84+
// Clean end: reset backoff and reconnect if still interested.
85+
backoff = INITIAL_BACKOFF_MS;
86+
} catch (error) {
87+
// Aborting the stream on close surfaces as an AbortError; that is an intentional teardown.
88+
if (error instanceof Error && error.name === 'AbortError') return;
89+
if (this.entries.size === 0) return;
90+
// Otherwise a routine disconnect — fall through to backoff + reconnect.
91+
}
92+
if (this.entries.size === 0) return;
93+
await new Promise((resolve) => setTimeout(resolve, backoff));
94+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
7495
}
7596
} finally {
7697
this.stream = null;

0 commit comments

Comments
 (0)