@@ -514,3 +514,146 @@ it('calling start twice does not create a second EventSource', () => {
514514
515515 base . close ( ) ;
516516} ) ;
517+
518+ it ( 'reads x-ld-fd-fallback-ttl on the error path' , async ( ) => {
519+ const mockEventSource = createMockEventSource ( ) ;
520+ const mockRequests = createMockRequests ( mockEventSource ) ;
521+ const base = createBase ( mockRequests , logger ) ;
522+ base . start ( ) ;
523+
524+ const willRetry = simulateErrorFilter ( mockRequests , {
525+ status : 200 ,
526+ message : 'fallback' ,
527+ headers : { 'x-ld-fd-fallback' : 'true' , 'x-ld-fd-fallback-ttl' : '60' } ,
528+ } ) ;
529+ expect ( willRetry ) . toBe ( false ) ;
530+
531+ const result = await base . takeResult ( ) ;
532+ expect ( result . type ) . toBe ( 'status' ) ;
533+ if ( result . type !== 'status' ) return ;
534+ expect ( result . state ) . toBe ( 'terminal_error' ) ;
535+ expect ( result . fdv1Fallback ) . toBe ( true ) ;
536+ expect ( ( result as any ) . fdv1FallbackTtlMs ) . toBe ( 60000 ) ;
537+
538+ base . close ( ) ;
539+ } ) ;
540+
541+ it ( 'defers the open fallback directive to the next changeSet' , async ( ) => {
542+ const mockEventSource = createMockEventSource ( ) ;
543+ const mockRequests = createMockRequests ( mockEventSource ) ;
544+ const base = createBase ( mockRequests , logger ) ;
545+ base . start ( ) ;
546+
547+ mockEventSource . onopen ( {
548+ type : 'open' ,
549+ headers : { 'x-ld-fd-fallback' : 'true' , 'x-ld-fd-fallback-ttl' : '90' } ,
550+ } ) ;
551+
552+ sendFullTransfer ( mockEventSource , [ { key : 'flag-a' , version : 1 , value : 'green' } ] ) ;
553+
554+ const result = await base . takeResult ( ) ;
555+ expect ( result . type ) . toBe ( 'changeSet' ) ;
556+ if ( result . type !== 'changeSet' ) return ;
557+ expect ( result . fdv1Fallback ) . toBe ( true ) ;
558+ expect ( ( result as any ) . fdv1FallbackTtlMs ) . toBe ( 90000 ) ;
559+
560+ base . close ( ) ;
561+ } ) ;
562+
563+ it ( 'emits terminal_error for a goodbye with protocolFallbackTTL' , async ( ) => {
564+ const mockEventSource = createMockEventSource ( ) ;
565+ const mockRequests = createMockRequests ( mockEventSource ) ;
566+ const base = createBase ( mockRequests , logger ) ;
567+ base . start ( ) ;
568+
569+ simulateEvent ( mockEventSource , 'goodbye' , {
570+ reason : 'falling back' ,
571+ protocolFallbackTTL : 60 ,
572+ } ) ;
573+
574+ const result = await base . takeResult ( ) ;
575+ expect ( result . type ) . toBe ( 'status' ) ;
576+ if ( result . type !== 'status' ) return ;
577+ expect ( result . state ) . toBe ( 'terminal_error' ) ;
578+ expect ( result . fdv1Fallback ) . toBe ( true ) ;
579+ expect ( ( result as any ) . fdv1FallbackTtlMs ) . toBe ( 60000 ) ;
580+
581+ base . close ( ) ;
582+ } ) ;
583+
584+ it ( 'emits terminal_error for a goodbye when a deferred open directive is pending' , async ( ) => {
585+ // When onopen sets pendingFallback but the goodbye event has no protocolFallbackTTL,
586+ // the pending directive triggers the fallback path with the deferred TTL.
587+ const mockEventSource = createMockEventSource ( ) ;
588+ const mockRequests = createMockRequests ( mockEventSource ) ;
589+ const base = createBase ( mockRequests , logger ) ;
590+ base . start ( ) ;
591+
592+ // Open with fallback headers (defers the directive)
593+ mockEventSource . onopen ( {
594+ type : 'open' ,
595+ headers : { 'x-ld-fd-fallback' : 'true' , 'x-ld-fd-fallback-ttl' : '45' } ,
596+ } ) ;
597+
598+ // Goodbye fires before any payload — the pending directive triggers the fallback
599+ simulateEvent ( mockEventSource , 'goodbye' , { reason : 'bye' } ) ;
600+
601+ const result = await base . takeResult ( ) ;
602+ expect ( result . type ) . toBe ( 'status' ) ;
603+ if ( result . type !== 'status' ) return ;
604+ expect ( result . state ) . toBe ( 'terminal_error' ) ;
605+ expect ( result . fdv1Fallback ) . toBe ( true ) ;
606+ expect ( result . fdv1FallbackTtlMs ) . toBe ( 45000 ) ;
607+
608+ base . close ( ) ;
609+ } ) ;
610+
611+ it ( 'clears a pending fallback directive when onopen fires without the fallback header' , async ( ) => {
612+ const mockEventSource = createMockEventSource ( ) ;
613+ const mockRequests = createMockRequests ( mockEventSource ) ;
614+ const base = createBase ( mockRequests , logger ) ;
615+ base . start ( ) ;
616+
617+ // First open carries fallback headers — arms the deferred directive
618+ mockEventSource . onopen ( {
619+ type : 'open' ,
620+ headers : { 'x-ld-fd-fallback' : 'true' , 'x-ld-fd-fallback-ttl' : '60' } ,
621+ } ) ;
622+
623+ // Reconnect without fallback header — must clear the stale directive
624+ mockEventSource . onopen ( { type : 'open' , headers : { } } ) ;
625+
626+ // A payload arriving after the clean reconnect must NOT carry fdv1Fallback=true
627+ sendFullTransfer ( mockEventSource , [ { key : 'flag-a' , version : 1 , value : 'blue' } ] ) ;
628+
629+ const result = await base . takeResult ( ) ;
630+ expect ( result . type ) . toBe ( 'changeSet' ) ;
631+ if ( result . type !== 'changeSet' ) return ;
632+ expect ( result . fdv1Fallback ) . toBe ( false ) ;
633+ expect ( result . fdv1FallbackTtlMs ) . toBeUndefined ( ) ;
634+
635+ base . close ( ) ;
636+ } ) ;
637+
638+ it ( 'close resets the pending fallback directive' , async ( ) => {
639+ // Calling close() must clear pendingFallback/pendingFallbackTtlMs so a
640+ // subsequently re-created source does not inherit stale state.
641+ const mockEventSource = createMockEventSource ( ) ;
642+ const mockRequests = createMockRequests ( mockEventSource ) ;
643+ const base = createBase ( mockRequests , logger ) ;
644+ base . start ( ) ;
645+
646+ // Arm the deferred directive
647+ mockEventSource . onopen ( {
648+ type : 'open' ,
649+ headers : { 'x-ld-fd-fallback' : 'true' , 'x-ld-fd-fallback-ttl' : '30' } ,
650+ } ) ;
651+
652+ // Close before any payload arrives — the shutdown result must NOT carry TTL
653+ base . close ( ) ;
654+ const result = await base . takeResult ( ) ;
655+ expect ( result . type ) . toBe ( 'status' ) ;
656+ if ( result . type !== 'status' ) return ;
657+ expect ( result . state ) . toBe ( 'shutdown' ) ;
658+ expect ( result . fdv1FallbackTtlMs ) . toBeUndefined ( ) ;
659+ } ) ;
0 commit comments