Skip to content

Commit 99d82a2

Browse files
HOYALIMsteipete
andauthored
fix(realtime): preserve reconnect budget across flaps (#102348)
Reset reconnect attempts only after a provider-ready connection stays stable, and cover both flap exhaustion and stable recovery. Co-authored-by: Peter Steinberger <[email protected]>
1 parent 9706aae commit 99d82a2

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

src/realtime-transcription/websocket-session.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ beforeEach(() => {
1616
});
1717

1818
afterEach(async () => {
19+
vi.restoreAllMocks();
1920
vi.useRealTimers();
2021
await cleanup?.();
2122
cleanup = undefined;
@@ -25,6 +26,7 @@ async function createRealtimeServer(params?: {
2526
closeOnConnection?: boolean;
2627
initialEvent?: unknown;
2728
initialText?: string;
29+
onConnection?: (ws: WebSocket) => void;
2830
onUpgrade?: (headers: Record<string, string | string[] | undefined>) => void;
2931
onBinary?: (payload: Buffer) => void;
3032
onText?: (payload: unknown) => void;
@@ -48,6 +50,7 @@ async function createRealtimeServer(params?: {
4850
if (params?.initialText) {
4951
ws.send(params.initialText);
5052
}
53+
params?.onConnection?.(ws);
5154
ws.on("message", (data, isBinary) => {
5255
const buffer = Buffer.isBuffer(data)
5356
? data
@@ -407,6 +410,94 @@ describe("createRealtimeTranscriptionWebSocketSession", () => {
407410
expect(closeError.message).toBe("test realtime transcription connection closed before ready");
408411
});
409412

413+
it("stops reconnecting after repeated ready-then-close flaps", async () => {
414+
const onError = vi.fn();
415+
let openCount = 0;
416+
const server = await createRealtimeServer({
417+
initialEvent: { type: "session.created" },
418+
onConnection: (ws) => {
419+
openCount += 1;
420+
setTimeout(() => ws.close(1011, "flap"), 1);
421+
},
422+
});
423+
const session = createRealtimeTranscriptionWebSocketSession<{ type?: string }>({
424+
providerId: "test",
425+
callbacks: { onError },
426+
url: server.url,
427+
maxReconnectAttempts: 3,
428+
reconnectDelayMs: 5,
429+
onMessage: (event, transport) => {
430+
if (event.type === "session.created") {
431+
transport.markReady();
432+
}
433+
},
434+
sendAudio: (audio, transport) => {
435+
transport.sendBinary(audio);
436+
},
437+
});
438+
439+
await session.connect();
440+
await vi.waitFor(
441+
() => {
442+
expect(onError).toHaveBeenCalledWith(
443+
expect.objectContaining({
444+
message: "test realtime transcription reconnect limit reached",
445+
}),
446+
);
447+
},
448+
{ timeout: 1000 },
449+
);
450+
expect(openCount).toBe(4);
451+
session.close();
452+
});
453+
454+
it("refreshes the reconnect budget after a stable ready connection", async () => {
455+
let now = 0;
456+
vi.spyOn(Date, "now").mockImplementation(() => now);
457+
const connections: WebSocket[] = [];
458+
const onError = vi.fn();
459+
const server = await createRealtimeServer({
460+
initialEvent: { type: "session.created" },
461+
onConnection: (ws) => connections.push(ws),
462+
});
463+
const session = createRealtimeTranscriptionWebSocketSession<{ type?: string }>({
464+
providerId: "test",
465+
callbacks: { onError },
466+
url: server.url,
467+
maxReconnectAttempts: 1,
468+
reconnectDelayMs: 5,
469+
onMessage: (event, transport) => {
470+
if (event.type === "session.created") {
471+
transport.markReady();
472+
}
473+
},
474+
sendAudio: (audio, transport) => {
475+
transport.sendBinary(audio);
476+
},
477+
});
478+
479+
await session.connect();
480+
connections[0]?.close(1011, "first flap");
481+
await vi.waitFor(() => expect(connections).toHaveLength(2));
482+
await vi.waitFor(() => expect(session.isConnected()).toBe(true));
483+
484+
now = 30_000;
485+
connections[1]?.close(1011, "stable disconnect");
486+
await vi.waitFor(() => expect(connections).toHaveLength(3));
487+
await vi.waitFor(() => expect(session.isConnected()).toBe(true));
488+
489+
connections[2]?.close(1011, "second flap");
490+
await vi.waitFor(() => {
491+
expect(onError).toHaveBeenCalledWith(
492+
expect.objectContaining({
493+
message: "test realtime transcription reconnect limit reached",
494+
}),
495+
);
496+
});
497+
expect(connections).toHaveLength(3);
498+
session.close();
499+
});
500+
410501
it("delivers a legitimate large inbound message below the payload cap", async () => {
411502
// Well above any real transcript message yet far under the 16 MiB cap: proves
412503
// the bound does not reject legitimate large provider traffic.

src/realtime-transcription/websocket-session.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const DEFAULT_CLOSE_TIMEOUT_MS = 5_000;
5050
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 5;
5151
const DEFAULT_RECONNECT_DELAY_MS = 1000;
5252
const DEFAULT_MAX_QUEUED_BYTES = 2 * 1024 * 1024;
53+
// A raw WebSocket open is not recovery. Only a provider-ready connection
54+
// that survives this window earns a fresh retry budget.
55+
const RECONNECT_STABLE_RESET_MS = 30_000;
5356
// Bound inbound messages before ws buffers them for JSON parsing. The 16 MiB cap
5457
// matches realtime voice; ws rejects larger messages with close 1009 before
5558
// they reach onMessage, replacing its 100 MiB client default.
@@ -81,6 +84,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
8184
private queuedAudio: Buffer[] = [];
8285
private queuedBytes = 0;
8386
private ready = false;
87+
private readySinceMs: number | undefined;
8488
private reconnectAttempts = 0;
8589
private reconnecting = false;
8690
private suppressReconnect = false;
@@ -111,6 +115,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
111115
async connect(): Promise<void> {
112116
this.closed = false;
113117
this.suppressReconnect = false;
118+
this.readySinceMs = undefined;
114119
this.reconnectAttempts = 0;
115120
await this.doConnect();
116121
}
@@ -132,6 +137,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
132137
this.closed = true;
133138
this.connected = false;
134139
this.ready = false;
140+
this.readySinceMs = undefined;
135141
this.queuedAudio = [];
136142
this.queuedBytes = 0;
137143
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -205,6 +211,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
205211
settled = true;
206212
clearConnectTimeout();
207213
this.ready = true;
214+
this.readySinceMs = Date.now();
208215
this.flushQueuedAudio();
209216
resolve();
210217
};
@@ -264,7 +271,6 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
264271
this.ws.on("open", () => {
265272
opened = true;
266273
this.connected = true;
267-
this.reconnectAttempts = 0;
268274
this.captureLocalOpen();
269275
try {
270276
this.options.onOpen?.(this.transport);
@@ -303,8 +309,13 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
303309
this.ws.on("close", (code, reasonBuffer) => {
304310
clearConnectTimeout();
305311
this.captureClose(code, reasonBuffer);
312+
const readyForMs = this.readySinceMs === undefined ? 0 : Date.now() - this.readySinceMs;
306313
this.connected = false;
307314
this.ready = false;
315+
this.readySinceMs = undefined;
316+
if (readyForMs >= RECONNECT_STABLE_RESET_MS) {
317+
this.reconnectAttempts = 0;
318+
}
308319
if (this.closeTimer) {
309320
clearTimeout(this.closeTimer);
310321
this.closeTimer = undefined;
@@ -422,6 +433,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
422433
}
423434
this.connected = false;
424435
this.ready = false;
436+
this.readySinceMs = undefined;
425437
if (this.ws) {
426438
this.ws.close(1000, "Transcription session closed");
427439
this.ws = null;

0 commit comments

Comments
 (0)