@@ -432,21 +432,35 @@ describe('OnyxUtils', () => {
432432 const diskFullError = new Error ( 'database or disk is full' ) ;
433433
434434 it ( 'should retry only one time if the operation is firstly failed and then passed' , async ( ) => {
435- StorageMock . setItem = jest . fn ( StorageMock . setItem ) . mockRejectedValueOnce ( genericError ) . mockImplementation ( StorageMock . setItem ) ;
435+ jest . useFakeTimers ( ) ;
436+ try {
437+ StorageMock . setItem = jest . fn ( StorageMock . setItem ) . mockRejectedValueOnce ( genericError ) . mockImplementation ( StorageMock . setItem ) ;
436438
437- await Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
439+ const setPromise = Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
440+ await jest . runAllTimersAsync ( ) ;
441+ await setPromise ;
438442
439- // Should be called once, since Storage.setItem if failed only once
440- expect ( retryOperationSpy ) . toHaveBeenCalledTimes ( 1 ) ;
443+ // Should be called once, since Storage.setItem failed only once
444+ expect ( retryOperationSpy ) . toHaveBeenCalledTimes ( 1 ) ;
445+ } finally {
446+ jest . useRealTimers ( ) ;
447+ }
441448 } ) ;
442449
443450 it ( 'should stop retrying after MAX_STORAGE_OPERATION_RETRY_ATTEMPTS retries for failing operation' , async ( ) => {
444- StorageMock . setItem = jest . fn ( ) . mockRejectedValue ( genericError ) ;
451+ jest . useFakeTimers ( ) ;
452+ try {
453+ StorageMock . setItem = jest . fn ( ) . mockRejectedValue ( genericError ) ;
445454
446- await Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
455+ const setPromise = Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
456+ await jest . runAllTimersAsync ( ) ;
457+ await setPromise ;
447458
448- // Should be called 6 times: initial attempt + 5 retries (MAX_STORAGE_OPERATION_RETRY_ATTEMPTS)
449- expect ( retryOperationSpy ) . toHaveBeenCalledTimes ( 6 ) ;
459+ // Should be called 6 times: initial attempt + 5 retries (MAX_STORAGE_OPERATION_RETRY_ATTEMPTS)
460+ expect ( retryOperationSpy ) . toHaveBeenCalledTimes ( 6 ) ;
461+ } finally {
462+ jest . useRealTimers ( ) ;
463+ }
450464 } ) ;
451465
452466 it ( "should throw error for if operation failed with \"Failed to execute 'put' on 'IDBObjectStore': invalid data\" error" , async ( ) => {
@@ -512,6 +526,72 @@ describe('OnyxUtils', () => {
512526 await OnyxUtils . remove ( evictableKey ) ;
513527 expect ( OnyxCache . getKeyForEviction ( ) ) . toBeUndefined ( ) ;
514528 } ) ;
529+
530+ it ( 'should apply exponential backoff delay for non-capacity errors' , async ( ) => {
531+ jest . useFakeTimers ( ) ;
532+ try {
533+ const setTimeoutSpy = jest . spyOn ( global , 'setTimeout' ) ;
534+ StorageMock . setItem = jest . fn ( ) . mockRejectedValue ( genericError ) ;
535+
536+ const setPromise = Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
537+ await jest . runAllTimersAsync ( ) ;
538+ await setPromise ;
539+
540+ // Filter setTimeout calls to only those from our wait() helper (delay > 0)
541+ const backoffDelays = setTimeoutSpy . mock . calls
542+ . map ( ( call ) => call [ 1 ] )
543+ . filter ( ( delay ) : delay is number => typeof delay === 'number' && delay > 0 ) ;
544+
545+ // Should have 5 backoff delays (one before each of the 5 retries, attempts 0-4)
546+ // The 6th call to retryOperation (attempt 5) hits the MAX check and resolves without waiting
547+ expect ( backoffDelays ) . toHaveLength ( 5 ) ;
548+
549+ // Verify exponential growth pattern: each delay should be roughly double the previous
550+ // With ±25% jitter, delay[n+1] / delay[n] should be between ~1.2 and ~3.3
551+ for ( let i = 1 ; i < backoffDelays . length ; i ++ ) {
552+ const ratio = backoffDelays [ i ] / backoffDelays [ i - 1 ] ;
553+ expect ( ratio ) . toBeGreaterThan ( 1.0 ) ;
554+ expect ( ratio ) . toBeLessThan ( 4.0 ) ;
555+ }
556+
557+ setTimeoutSpy . mockRestore ( ) ;
558+ } finally {
559+ jest . useRealTimers ( ) ;
560+ }
561+ } ) ;
562+
563+ it ( 'should log connection error with backoff delay info' , async ( ) => {
564+ jest . useFakeTimers ( ) ;
565+ try {
566+ const logInfoSpy = jest . spyOn ( Logger , 'logInfo' ) ;
567+ const connectionError = new Error ( 'Connection to Indexed Database server lost. Refresh the page to try again' ) ;
568+ StorageMock . setItem = jest . fn ( StorageMock . setItem ) . mockRejectedValueOnce ( connectionError ) . mockImplementation ( StorageMock . setItem ) ;
569+
570+ const setPromise = Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
571+ await jest . runAllTimersAsync ( ) ;
572+ await setPromise ;
573+
574+ expect ( logInfoSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Connection error detected, retrying with backoff' ) ) ;
575+ expect ( logInfoSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Connection to Indexed Database server lost' ) ) ;
576+ } finally {
577+ jest . useRealTimers ( ) ;
578+ }
579+ } ) ;
580+
581+ it ( 'should NOT apply backoff delay for capacity errors (immediate retry with eviction)' , async ( ) => {
582+ const setTimeoutSpy = jest . spyOn ( global , 'setTimeout' ) ;
583+ StorageMock . setItem = jest . fn ( ) . mockRejectedValue ( diskFullError ) ;
584+
585+ await Onyx . set ( ONYXKEYS . TEST_KEY , { test : 'data' } ) ;
586+
587+ // Capacity errors should not trigger any backoff delays (delay > 0)
588+ const backoffDelays = setTimeoutSpy . mock . calls
589+ . map ( ( call ) => call [ 1 ] )
590+ . filter ( ( delay ) : delay is number => typeof delay === 'number' && delay > 0 ) ;
591+
592+ expect ( backoffDelays ) . toHaveLength ( 0 ) ;
593+ setTimeoutSpy . mockRestore ( ) ;
594+ } ) ;
515595 } ) ;
516596
517597 describe ( 'storage eviction' , ( ) => {
0 commit comments