Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API-INTERNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ If the requested key is a collection, it will return an object with all the coll
<ul>
<li>Storage capacity errors: evicts data and retries the operation</li>
<li>Invalid data errors: logs an alert and throws an error</li>
<li>Non-retriable errors: logs an alert and resolves without retrying</li>
<li>Other errors: retries the operation</li>
</ul>
</dd>
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -786,6 +794,7 @@ function reportStorageQuota(error?: Error): Promise<void> {
* 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<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined): Promise<void> {
Expand All @@ -802,6 +811,12 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(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}.`);
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/onyxUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading