Skip to content

Commit bf9cf5f

Browse files
leshniakclaude
andcommitted
test: update retry tests for backoff and add backoff-specific tests
Existing retry tests now use fake timers to handle backoff delays. New tests verify: exponential delay progression, connection error logging, and capacity errors remaining immediate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16aef2e commit bf9cf5f

1 file changed

Lines changed: 88 additions & 8 deletions

File tree

tests/unit/onyxUtilsTest.ts

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)