Skip to content

Commit 8149b96

Browse files
committed
fix: preserve network recovery with a separate stall limit
1 parent 10f1d02 commit 8149b96

5 files changed

Lines changed: 145 additions & 58 deletions

File tree

.changeset/quiet-chat-stream-retries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"@trigger.dev/sdk": patch
44
---
55

6-
Chat streams now stop after five failed connection retries and report a terminal error instead of remaining active indefinitely. Internal timeout exhaustion reports an error, while caller cancellation still closes cleanly. Watch subscriptions continue to retry without a fixed limit.
6+
Chat streams now report an error after five retries of a connected stream that sends no records. Network failures and browser wakeups retain automatic recovery. Healthy tool calls with no records for about six minutes also reach this silence limit. Watch subscriptions remain unlimited, and caller cancellation still closes cleanly.

packages/core/src/v3/apiClient/runStream-retries.test.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe("SSE retry exhaustion", () => {
88
let abort: AbortController;
99
let attempts: number;
1010
let respond: (response: ServerResponse) => void;
11+
let subscription: SSEStreamSubscription;
1112

1213
beforeEach(async () => {
1314
attempts = 0;
@@ -28,16 +29,22 @@ describe("SSE retry exhaustion", () => {
2829
await new Promise<void>((resolve) => server.close(() => resolve()));
2930
});
3031

31-
async function open(options: { fetchTimeoutMs?: number; stallTimeoutMs?: number } = {}) {
32-
return (
33-
await new SSEStreamSubscription(url, {
34-
signal: abort.signal,
35-
maxRetries: 2,
36-
retryDelayMs: 1,
37-
retryJitter: 0,
38-
...options,
39-
}).subscribe()
40-
).getReader();
32+
async function open(
33+
options: {
34+
fetchTimeoutMs?: number;
35+
stallTimeoutMs?: number;
36+
maxRetries?: number;
37+
maxStallRetries?: number;
38+
} = {}
39+
) {
40+
subscription = new SSEStreamSubscription(url, {
41+
signal: abort.signal,
42+
maxRetries: 2,
43+
retryDelayMs: 1,
44+
retryJitter: 0,
45+
...options,
46+
});
47+
return (await subscription.subscribe()).getReader();
4148
}
4249

4350
it.each(["fetch", "stall"] as const)(
@@ -74,7 +81,7 @@ describe("SSE retry exhaustion", () => {
7481
});
7582
response.write(payload);
7683
};
77-
const reader = await open({ stallTimeoutMs: 100 });
84+
const reader = await open({ stallTimeoutMs: 100, maxRetries: Infinity, maxStallRetries: 2 });
7885

7986
await expect(reader.read()).rejects.toThrow("Stream connection retries exhausted");
8087
expect(attempts).toBe(3);
@@ -103,4 +110,79 @@ describe("SSE retry exhaustion", () => {
103110
expect(await reader.read()).toEqual({ done: true, value: undefined });
104111
expect(attempts).toBe(1);
105112
});
113+
114+
it("limits silent stalls without a general retry limit", async () => {
115+
respond = (response) => {
116+
response.writeHead(200, { "Content-Type": "text/event-stream" });
117+
response.flushHeaders();
118+
};
119+
const reader = await open({ stallTimeoutMs: 100, maxRetries: Infinity, maxStallRetries: 2 });
120+
121+
await expect(reader.read()).rejects.toThrow("Stream connection retries exhausted");
122+
expect(attempts).toBe(3);
123+
});
124+
125+
it("restores the stall budget only after a decoded record", async () => {
126+
respond = (response) => {
127+
response.writeHead(200, { "Content-Type": "text/event-stream" });
128+
response.flushHeaders();
129+
if (attempts === 3) response.write('id: 1\ndata: {"hello":1}\n\n');
130+
};
131+
const reader = await open({ stallTimeoutMs: 100, maxRetries: Infinity, maxStallRetries: 2 });
132+
133+
expect(await reader.read()).toMatchObject({ done: false, value: { chunk: { hello: 1 } } });
134+
await expect(reader.read()).rejects.toThrow("Stream connection retries exhausted");
135+
expect(attempts).toBe(5);
136+
});
137+
138+
it.each(["http", "fetch", "body", "wake"] as const)(
139+
"does not charge %s failures to the stall budget",
140+
async (failure) => {
141+
respond = (response) => {
142+
if (attempts === 5) {
143+
response.writeHead(200, { "Content-Type": "text/event-stream" });
144+
response.end('id: 1\ndata: {"hello":1}\n\n');
145+
} else if (failure === "http") {
146+
response.writeHead(503).end();
147+
} else if (failure !== "fetch") {
148+
response.writeHead(200, { "Content-Type": "text/event-stream" });
149+
response.write(": keepalive\n\n");
150+
setTimeout(() => {
151+
if (failure === "wake") subscription.forceReconnect();
152+
else response.destroy();
153+
}, 10);
154+
}
155+
};
156+
const reader = await open({
157+
maxRetries: Infinity,
158+
maxStallRetries: 0,
159+
fetchTimeoutMs: 100,
160+
stallTimeoutMs: 1_000,
161+
});
162+
163+
expect(await reader.read()).toMatchObject({ done: false, value: { chunk: { hello: 1 } } });
164+
expect(attempts).toBe(5);
165+
}
166+
);
167+
168+
it("retains the stall budget across connection failures and wakeups", async () => {
169+
respond = (response) => {
170+
if (attempts === 2) {
171+
response.writeHead(503).end();
172+
} else if (attempts !== 4) {
173+
response.writeHead(200, { "Content-Type": "text/event-stream" });
174+
response.flushHeaders();
175+
if (attempts === 3) setTimeout(() => subscription.forceReconnect(), 10);
176+
}
177+
};
178+
const reader = await open({
179+
maxRetries: Infinity,
180+
maxStallRetries: 1,
181+
fetchTimeoutMs: 100,
182+
stallTimeoutMs: 100,
183+
});
184+
185+
await expect(reader.read()).rejects.toThrow("Stream connection retries exhausted");
186+
expect(attempts).toBe(5);
187+
});
106188
});

packages/core/src/v3/apiClient/runStream.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export class SSEStreamSubscription implements StreamSubscription {
219219
private lastEventId: string | undefined;
220220
private from: "beginning" | "latest";
221221
private retryCount = 0;
222+
private stallCount = 0;
222223
private maxRetries: number;
223224
private retryDelayMs: number;
224225
private maxRetryDelayMs: number;
@@ -275,6 +276,9 @@ export class SSEStreamSubscription implements StreamSubscription {
275276
// the read just blocks). Disabled (`0`) by default; opt in
276277
// explicitly. Only decoded records reset the timer.
277278
stallTimeoutMs?: number;
279+
// Reconnects after stall timeouts before the stream errors.
280+
// Only decoded records restore this budget. Defaults to Infinity.
281+
maxStallRetries?: number;
278282
// HTTP statuses that should NOT be retried — fail the stream
279283
// permanently. Defaults cover the permanent client-error set:
280284
// `400` (bad request), `404` (stream gone), `409` (conflict),
@@ -402,7 +406,11 @@ export class SSEStreamSubscription implements StreamSubscription {
402406
const armStall = () => {
403407
if (this.stallTimeoutMs <= 0) return;
404408
clearTimeout(stallTimer);
405-
stallTimer = setTimeout(() => this.internalAbort?.abort(), this.stallTimeoutMs);
409+
stallTimer = setTimeout(() => {
410+
if (!this.internalAbort || this.internalAbort.signal.aborted) return;
411+
this.stallCount++;
412+
this.internalAbort.abort();
413+
}, this.stallTimeoutMs);
406414
};
407415

408416
// Idempotent — both the catch (before recursion) and the finally
@@ -578,6 +586,7 @@ export class SSEStreamSubscription implements StreamSubscription {
578586
this.authRefreshed = false;
579587
// Headers alone do not establish stream recovery.
580588
this.retryCount = 0;
589+
this.stallCount = 0;
581590
controller.enqueue(value);
582591
}
583592
} catch (error) {
@@ -644,7 +653,10 @@ export class SSEStreamSubscription implements StreamSubscription {
644653
return;
645654
}
646655

647-
if (this.retryCount >= this.maxRetries) {
656+
if (
657+
this.retryCount >= this.maxRetries ||
658+
this.stallCount > (this.options.maxStallRetries ?? Infinity)
659+
) {
648660
// Internal timeouts are failures, not caller cancellation.
649661
const finalError =
650662
error?.name === "AbortError"

packages/trigger-sdk/src/v3/chat-retries.test.ts

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -48,43 +48,38 @@ describe("Chat subscription retry exhaustion", () => {
4848
expect(events.filter((event) => event.type === "stream-error")).toHaveLength(1);
4949
});
5050

51-
it("limits failed connections and reports a terminal stream error", async () => {
52-
respond = (response) => response.writeHead(503).end();
53-
const stream = await transport.reconnectToStream({ chatId: "chat" });
54-
if (!stream) throw new Error("Expected a resumed stream");
55-
56-
await expect(stream.getReader().read()).rejects.toMatchObject({ status: 503 });
57-
expect(attempts).toBe(6);
58-
expect(transport.getSession("chat")?.isStreaming).toBe(false);
59-
expect(await transport.reconnectToStream({ chatId: "chat" })).toBeNull();
60-
expect(events.filter((event) => event.type === "stream-error")).toHaveLength(1);
61-
}, 25_000);
62-
63-
it("preserves unlimited retries for watch subscriptions", async () => {
64-
transport.dispose();
65-
transport = createChatTransport({
66-
task: "chat-task",
67-
baseURL,
68-
watch: true,
69-
sessions: { chat: { publicAccessToken: "test-token", isStreaming: true } },
70-
accessToken: () => "test-token",
71-
});
72-
respond = (response) => {
73-
if (attempts <= 6) {
74-
response.writeHead(503).end();
75-
return;
76-
}
77-
response.writeHead(200, { "Content-Type": "text/event-stream" });
78-
response.write('id: 1\ndata: {"type":"start","messageId":"assistant"}\n\n');
79-
};
80-
const stream = await transport.reconnectToStream({ chatId: "chat" });
81-
if (!stream) throw new Error("Expected a resumed stream");
82-
const reader = stream.getReader();
51+
it.each([false, true])(
52+
"recovers after six connection failures (watch: %s)",
53+
async (watch) => {
54+
transport.dispose();
55+
transport = createChatTransport({
56+
task: "chat-task",
57+
baseURL,
58+
watch,
59+
sessions: { chat: { publicAccessToken: "test-token", isStreaming: true } },
60+
accessToken: () => "test-token",
61+
onEvent: (event) => events.push(event),
62+
});
63+
respond = (response) => {
64+
if (attempts <= 6) {
65+
response.writeHead(503).end();
66+
return;
67+
}
68+
response.writeHead(200, { "Content-Type": "text/event-stream" });
69+
response.write('id: 1\ndata: {"type":"start","messageId":"assistant"}\n\n');
70+
};
71+
const stream = await transport.reconnectToStream({ chatId: "chat" });
72+
if (!stream) throw new Error("Expected a resumed stream");
73+
const reader = stream.getReader();
8374

84-
expect(await reader.read()).toMatchObject({ done: false, value: { type: "start" } });
85-
expect(attempts).toBe(7);
86-
await reader.cancel();
87-
}, 12_000);
75+
expect(await reader.read()).toMatchObject({ done: false, value: { type: "start" } });
76+
expect(attempts).toBe(7);
77+
expect(transport.getSession("chat")?.isStreaming).toBe(true);
78+
expect(events.filter((event) => event.type === "stream-error")).toHaveLength(0);
79+
await reader.cancel();
80+
},
81+
30_000
82+
);
8883

8984
it.each(["resolve", "reject"] as const)(
9085
"keeps the new stream after a late token refresh: %s",

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,9 +2092,12 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
20922092
lastEventId: state.lastEventId,
20932093
// Reconnect if no decoded record arrives for 60 seconds.
20942094
stallTimeoutMs: 60_000,
2095-
// Normal chat streams must reach a terminal error. Watch subscriptions stay open.
2096-
maxRetries: this.watchMode ? Infinity : 5,
2097-
retryDelayMs: this.watchMode ? undefined : 1_000,
2095+
// Bound connected silence while preserving recovery from network failures.
2096+
...(!this.watchMode && {
2097+
maxStallRetries: 5,
2098+
retryDelayMs: 1_000,
2099+
maxRetryDelayMs: 5_000,
2100+
}),
20982101
fetchClient: sseFetchClient,
20992102
});
21002103
currentSubscription = subscription;
@@ -2152,11 +2155,6 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
21522155
!currentSubscription?.sessionSettled &&
21532156
!combinedSignal.aborted
21542157
) {
2155-
// Clear + persist before throwing so the surfaced error leaves
2156-
// consistent state — otherwise a reload sees isStreaming: true
2157-
// and reopens a doomed subscription.
2158-
state.isStreaming = false;
2159-
this.notifySessionChange(chatId, state);
21602158
throw new Error(
21612159
"Chat stream ended before the turn completed (reconnect budget exhausted)."
21622160
);

0 commit comments

Comments
 (0)