@@ -93,7 +93,7 @@ describe('MessageQueue', () => {
9393 expect ( send ) . not . toHaveBeenCalled ( ) ;
9494
9595 queue . enqueue ( makeMessage ( '2' ) ) ;
96- // flush is async — await the microtask
96+ // flush is async, await the microtask
9797 await Promise . resolve ( ) ;
9898 expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
9999 } ) ;
@@ -326,6 +326,180 @@ describe('MessageQueue', () => {
326326 } ) ;
327327} ) ;
328328
329+ describe ( 'exponential backoff' , ( ) => {
330+ it ( 'skips flush while inside the backoff window' , async ( ) => {
331+ const start = 1_000_000 ;
332+ jest . setSystemTime ( start ) ;
333+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( failResult ) ;
334+ const queue = createQueue ( send ) ;
335+
336+ queue . enqueue ( makeMessage ( '1' ) ) ;
337+
338+ // First flush: records failure, sets backoff to start+5000
339+ await queue . flush ( ) ;
340+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
341+
342+ // Still inside backoff window: flush is a no-op
343+ jest . setSystemTime ( start + 4_999 ) ;
344+ await queue . flush ( ) ;
345+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
346+
347+ // Past backoff window: flush proceeds
348+ jest . setSystemTime ( start + 5_001 ) ;
349+ await queue . flush ( ) ;
350+ expect ( send ) . toHaveBeenCalledTimes ( 2 ) ;
351+ } ) ;
352+
353+ it ( 'escalates backoff: 5s → 10s → 20s → 40s → 60s' , async ( ) => {
354+ // Each step: trigger a failure, assert blocked before window, assert unblocked after.
355+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( failResult ) ;
356+ const queue = createQueue ( send ) ;
357+ queue . enqueue ( makeMessage ( '1' ) ) ;
358+
359+ let now = 1_000_000 ;
360+ let calls = 0 ;
361+
362+ const step = async ( delay : number ) => {
363+ jest . setSystemTime ( now ) ;
364+ await queue . flush ( ) ;
365+ calls ++ ;
366+ expect ( send ) . toHaveBeenCalledTimes ( calls ) ;
367+
368+ jest . setSystemTime ( now + delay - 1 ) ;
369+ await queue . flush ( ) ;
370+ expect ( send ) . toHaveBeenCalledTimes ( calls ) ; // still blocked
371+
372+ now += delay + 1 ;
373+ jest . setSystemTime ( now ) ;
374+ } ;
375+
376+ await step ( 5_000 ) ;
377+ await step ( 10_000 ) ;
378+ await step ( 20_000 ) ;
379+ await step ( 40_000 ) ;
380+ await step ( 60_000 ) ;
381+ } ) ;
382+
383+ it ( 'resets backoff on a successful flush' , async ( ) => {
384+ const start = 1_000_000 ;
385+ jest . setSystemTime ( start ) ;
386+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( )
387+ . mockResolvedValueOnce ( failResult )
388+ . mockResolvedValue ( okResult ) ;
389+ const queue = createQueue ( send ) ;
390+
391+ queue . enqueue ( makeMessage ( '1' ) ) ;
392+ queue . enqueue ( makeMessage ( '2' ) ) ;
393+
394+ // First flush fails; backoff starts
395+ await queue . flush ( ) ;
396+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
397+
398+ // Advance past the 5s window; second flush succeeds, backoff resets
399+ jest . setSystemTime ( start + 5_001 ) ;
400+ await queue . flush ( ) ;
401+ expect ( send ) . toHaveBeenCalledTimes ( 2 ) ;
402+
403+ // Should be able to flush immediately after reset
404+ queue . enqueue ( makeMessage ( '3' ) ) ;
405+ await queue . flush ( ) ;
406+ expect ( send ) . toHaveBeenCalledTimes ( 3 ) ;
407+ } ) ;
408+
409+ it ( 'uses Retry-After delay when server supplies it on 429' , async ( ) => {
410+ const start = 1_000_000 ;
411+ jest . setSystemTime ( start ) ;
412+ const rateLimitResult : TransportResult = {
413+ ok : false ,
414+ error : new TransportError ( { status : 429 , endpoint : 'https://api.immutable.com/v1/audience/messages' } ) ,
415+ retryAfterMs : 30_000 ,
416+ } ;
417+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( )
418+ . mockResolvedValueOnce ( rateLimitResult )
419+ . mockResolvedValue ( okResult ) ;
420+ const queue = createQueue ( send ) ;
421+
422+ queue . enqueue ( makeMessage ( '1' ) ) ;
423+
424+ await queue . flush ( ) ;
425+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
426+
427+ // 29s: still inside Retry-After window
428+ jest . setSystemTime ( start + 29_000 ) ;
429+ await queue . flush ( ) ;
430+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
431+
432+ // Past the window
433+ jest . setSystemTime ( start + 30_001 ) ;
434+ await queue . flush ( ) ;
435+ expect ( send ) . toHaveBeenCalledTimes ( 2 ) ;
436+ expect ( queue . length ) . toBe ( 0 ) ;
437+ } ) ;
438+
439+ it ( 'fires RATE_LIMITED via onError on 429 and keeps the batch' , async ( ) => {
440+ const onError = jest . fn ( ) ;
441+ const rateLimitResult : TransportResult = {
442+ ok : false ,
443+ error : new TransportError ( { status : 429 , endpoint : 'https://api.immutable.com/v1/audience/messages' } ) ,
444+ } ;
445+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( rateLimitResult ) ;
446+ const queue = createQueue ( send , { onError } ) ;
447+
448+ queue . enqueue ( makeMessage ( '1' ) ) ;
449+ await queue . flush ( ) ;
450+
451+ expect ( queue . length ) . toBe ( 1 ) ;
452+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
453+ expect ( onError . mock . calls [ 0 ] [ 0 ] . code ) . toBe ( 'RATE_LIMITED' ) ;
454+ expect ( onError . mock . calls [ 0 ] [ 0 ] . status ) . toBe ( 429 ) ;
455+ } ) ;
456+ } ) ;
457+
458+ describe ( '4xx drop' , ( ) => {
459+ it ( 'drops batch and fires VALIDATION_REJECTED on non-retryable 4xx' , async ( ) => {
460+ const onError = jest . fn ( ) ;
461+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( {
462+ ok : false ,
463+ error : new TransportError ( {
464+ status : 401 ,
465+ endpoint : 'https://api.immutable.com/v1/audience/messages' ,
466+ body : 'Unauthorized' ,
467+ } ) ,
468+ } ) ;
469+ const queue = createQueue ( send , { onError } ) ;
470+
471+ queue . enqueue ( makeMessage ( '1' ) ) ;
472+ queue . enqueue ( makeMessage ( '2' ) ) ;
473+ await queue . flush ( ) ;
474+
475+ expect ( queue . length ) . toBe ( 0 ) ;
476+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
477+ const err = onError . mock . calls [ 0 ] [ 0 ] ;
478+ expect ( err . code ) . toBe ( 'VALIDATION_REJECTED' ) ;
479+ expect ( err . status ) . toBe ( 401 ) ;
480+ } ) ;
481+
482+ it ( 'drops batch and fires VALIDATION_REJECTED on 400' , async ( ) => {
483+ const onError = jest . fn ( ) ;
484+ const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( {
485+ ok : false ,
486+ error : new TransportError ( {
487+ status : 400 ,
488+ endpoint : 'https://api.immutable.com/v1/audience/messages' ,
489+ body : { error : 'bad request' } ,
490+ } ) ,
491+ } ) ;
492+ const queue = createQueue ( send , { onError } ) ;
493+
494+ queue . enqueue ( makeMessage ( '1' ) ) ;
495+ await queue . flush ( ) ;
496+
497+ expect ( queue . length ) . toBe ( 0 ) ;
498+ expect ( onError . mock . calls [ 0 ] [ 0 ] . code ) . toBe ( 'VALIDATION_REJECTED' ) ;
499+ expect ( onError . mock . calls [ 0 ] [ 0 ] . status ) . toBe ( 400 ) ;
500+ } ) ;
501+ } ) ;
502+
329503describe ( 'page-unload flush (keepalive)' , ( ) => {
330504 it ( 'flushes via keepalive fetch on visibilitychange to hidden' , ( ) => {
331505 const send = jest . fn < ReturnType < HttpSend > , Parameters < HttpSend > > ( ) . mockResolvedValue ( okResult ) ;
@@ -417,7 +591,7 @@ describe('page-unload flush (keepalive)', () => {
417591 ) ;
418592 expect ( queue . length ) . toBe ( 0 ) ;
419593
420- // Listeners removed — no double flush
594+ // Listeners removed - no double flush
421595 queue . enqueue ( makeMessage ( '3' ) ) ;
422596 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
423597 expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
@@ -435,7 +609,7 @@ describe('page-unload flush (keepalive)', () => {
435609 // Start an async flush (sets flushing = true)
436610 const pending = queue . flush ( ) ;
437611
438- // pagehide fires while async flush is in flight — unload flush should be skipped
612+ // pagehide fires while async flush is in flight; unload flush should be skipped
439613 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
440614 // Only 1 call (the async flush), no keepalive call
441615 expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
0 commit comments