Skip to content

Commit c37203f

Browse files
committed
fix(realtime): preserve reconnect backoff across flaps
Signed-off-by: Ho Lim <[email protected]>
1 parent f85d438 commit c37203f

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async function createRealtimeServer(params?: {
2222
closeOnConnection?: boolean;
2323
initialEvent?: unknown;
2424
initialText?: string;
25+
onConnection?: (client: WebSocket) => void;
2526
onUpgrade?: (headers: Record<string, string | string[] | undefined>) => void;
2627
onBinary?: (payload: Buffer) => void;
2728
onText?: (payload: unknown) => void;
@@ -45,6 +46,7 @@ async function createRealtimeServer(params?: {
4546
if (params?.initialText) {
4647
ws.send(params.initialText);
4748
}
49+
params?.onConnection?.(ws);
4850
ws.on("message", (data, isBinary) => {
4951
const buffer = Buffer.isBuffer(data)
5052
? data
@@ -403,4 +405,46 @@ describe("createRealtimeTranscriptionWebSocketSession", () => {
403405
expect(closeError).toBeInstanceOf(Error);
404406
expect(closeError.message).toBe("test realtime transcription connection closed before ready");
405407
});
408+
409+
it("stops reconnecting after repeated ready-then-close flaps", async () => {
410+
const onError = vi.fn();
411+
let openCount = 0;
412+
const server = await createRealtimeServer({
413+
initialEvent: { type: "session.created" },
414+
onConnection: (ws) => {
415+
openCount += 1;
416+
setTimeout(() => ws.close(1011, "flap"), 1);
417+
},
418+
});
419+
const session = createRealtimeTranscriptionWebSocketSession<{ type?: string }>({
420+
providerId: "test",
421+
callbacks: { onError },
422+
url: server.url,
423+
maxReconnectAttempts: 3,
424+
reconnectDelayMs: 5,
425+
onMessage: (event, transport) => {
426+
if (event.type === "session.created") {
427+
transport.markReady();
428+
}
429+
},
430+
sendAudio: (audio, transport) => {
431+
transport.sendBinary(audio);
432+
},
433+
});
434+
435+
await session.connect();
436+
437+
await vi.waitFor(
438+
() => {
439+
expect(onError).toHaveBeenCalledWith(
440+
expect.objectContaining({
441+
message: "test realtime transcription reconnect limit reached",
442+
}),
443+
);
444+
},
445+
{ timeout: 1000 },
446+
);
447+
expect(openCount).toBe(4);
448+
session.close();
449+
});
406450
});

src/realtime-transcription/websocket-session.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ 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+
const RECONNECT_STABLE_RESET_MS = 30_000;
5354

5455
function rawWsDataToBuffer(data: RawData): Buffer {
5556
if (Buffer.isBuffer(data)) {
@@ -78,6 +79,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
7879
private queuedBytes = 0;
7980
private ready = false;
8081
private reconnectAttempts = 0;
82+
private reconnectStableTimer: ReturnType<typeof setTimeout> | undefined;
8183
private reconnecting = false;
8284
private suppressReconnect = false;
8385
private ws: WebSocket | null = null;
@@ -108,6 +110,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
108110
this.closed = false;
109111
this.suppressReconnect = false;
110112
this.reconnectAttempts = 0;
113+
this.clearReconnectStableReset();
111114
await this.doConnect();
112115
}
113116

@@ -128,6 +131,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
128131
this.closed = true;
129132
this.connected = false;
130133
this.ready = false;
134+
this.clearReconnectStableReset();
131135
this.queuedAudio = [];
132136
this.queuedBytes = 0;
133137
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -201,6 +205,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
201205
settled = true;
202206
clearConnectTimeout();
203207
this.ready = true;
208+
this.scheduleReconnectStableReset();
204209
this.flushQueuedAudio();
205210
resolve();
206211
};
@@ -259,7 +264,6 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
259264
this.ws.on("open", () => {
260265
opened = true;
261266
this.connected = true;
262-
this.reconnectAttempts = 0;
263267
this.captureLocalOpen();
264268
try {
265269
this.options.onOpen?.(this.transport);
@@ -300,6 +304,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
300304
this.captureClose(code, reasonBuffer);
301305
this.connected = false;
302306
this.ready = false;
307+
this.clearReconnectStableReset();
303308
if (this.closeTimer) {
304309
clearTimeout(this.closeTimer);
305310
this.closeTimer = undefined;
@@ -415,6 +420,7 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
415420
clearTimeout(this.closeTimer);
416421
this.closeTimer = undefined;
417422
}
423+
this.clearReconnectStableReset();
418424
this.connected = false;
419425
this.ready = false;
420426
if (this.ws) {
@@ -423,6 +429,27 @@ class WebSocketRealtimeTranscriptionSession<Event> implements RealtimeTranscript
423429
}
424430
}
425431

432+
private scheduleReconnectStableReset(): void {
433+
this.clearReconnectStableReset();
434+
if (this.reconnectAttempts <= 0) {
435+
return;
436+
}
437+
this.reconnectStableTimer = setTimeout(() => {
438+
this.reconnectStableTimer = undefined;
439+
if (this.connected && this.ready) {
440+
this.reconnectAttempts = 0;
441+
}
442+
}, RECONNECT_STABLE_RESET_MS);
443+
}
444+
445+
private clearReconnectStableReset(): void {
446+
if (!this.reconnectStableTimer) {
447+
return;
448+
}
449+
clearTimeout(this.reconnectStableTimer);
450+
this.reconnectStableTimer = undefined;
451+
}
452+
426453
private emitError(error: unknown): void {
427454
const normalized = error instanceof Error ? error : new Error(String(error));
428455
try {

0 commit comments

Comments
 (0)