@@ -16,6 +16,7 @@ beforeEach(() => {
1616} ) ;
1717
1818afterEach ( 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.
0 commit comments