diff --git a/API-INTERNAL.md b/API-INTERNAL.md index c84d29afb..b5a9f55be 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -83,6 +83,7 @@ If the requested key is a collection, it will return an object with all the coll @@ -323,6 +324,7 @@ Remove a key from Onyx and update the subscribers Handles storage operation failures based on the error type: - Storage capacity errors: evicts data and retries the operation - Invalid data errors: logs an alert and throws an error +- Non-retriable errors: logs an alert and resolves without retrying - Other errors: retries the operation **Kind**: global function diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 56dbb10fb..0c5f764f3 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -61,6 +61,14 @@ const SQLITE_STORAGE_ERRORS = [ const STORAGE_ERRORS = [...IDB_STORAGE_ERRORS, ...SQLITE_STORAGE_ERRORS]; +// IndexedDB errors where retrying is futile because the underlying connection/store is broken. +// The healing path (separate from retryOperation) is responsible for recovery. +const IDB_NON_RETRIABLE_ERRORS = [ + 'internal error opening backing store', // LevelDB backing store is broken at the filesystem level +] as const; + +const NON_RETRIABLE_ERRORS = [...IDB_NON_RETRIABLE_ERRORS]; + // Max number of retries for failed storage operations const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5; @@ -786,6 +794,7 @@ function reportStorageQuota(error?: Error): Promise { * Handles storage operation failures based on the error type: * - Storage capacity errors: evicts data and retries the operation * - Invalid data errors: logs an alert and throws an error + * - Non-retriable errors: logs an alert and resolves without retrying * - Other errors: retries the operation */ function retryOperation(error: Error, onyxMethod: TMethod, defaultParams: Parameters[0], retryAttempt: number | undefined): Promise { @@ -802,6 +811,12 @@ function retryOperation(error: Error, on const errorMessage = error?.message?.toLowerCase?.(); const errorName = error?.name?.toLowerCase?.(); const isStorageCapacityError = STORAGE_ERRORS.some((storageError) => errorName?.includes(storageError) || errorMessage?.includes(storageError)); + const isNonRetriableError = NON_RETRIABLE_ERRORS.some((nonRetriableError) => errorName?.includes(nonRetriableError) || errorMessage?.includes(nonRetriableError)); + + if (isNonRetriableError) { + Logger.logAlert(`Storage operation skipped retry for non-retriable error. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); + return Promise.resolve(); + } if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) { Logger.logAlert(`Storage operation failed after 5 retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index cc1568365..dcd2f0b77 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -788,6 +788,7 @@ describe('OnyxUtils', () => { const genericError = new Error('Generic storage error'); const invalidDataError = new Error("Failed to execute 'put' on 'IDBObjectStore': invalid data"); const diskFullError = new Error('database or disk is full'); + const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'}); it('should retry only one time if the operation is firstly failed and then passed', async () => { StorageMock.setItem = jest.fn(StorageMock.setItem).mockRejectedValueOnce(genericError).mockImplementation(StorageMock.setItem); @@ -822,6 +823,26 @@ describe('OnyxUtils', () => { expect(retryOperationSpy).toHaveBeenCalledTimes(1); }); + it('should not retry for non-retriable IndexedDB backing-store errors', async () => { + StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // Called once (initial attempt only) -- no recursion, unlike the 6 calls for generic errors + expect(retryOperationSpy).toHaveBeenCalledTimes(1); + }); + + it('should log a single skip alert for non-retriable errors', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + expect(logAlertSpy).toHaveBeenCalledWith(`Storage operation skipped retry for non-retriable error. Error: ${nonRetriableIdbError}. onyxMethod: setWithRetry.`); + // Not paired with the "5 retries exhausted" alert + expect(logAlertSpy).toHaveBeenCalledTimes(1); + }); + it('should include the error in logAlert for IDBObjectStore invalid data errors', async () => { const logAlertSpy = jest.spyOn(Logger, 'logAlert'); StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError);