diff --git a/API-INTERNAL.md b/API-INTERNAL.md
index b3cfeb90a..54dc85d21 100644
--- a/API-INTERNAL.md
+++ b/API-INTERNAL.md
@@ -79,12 +79,18 @@ If the requested key is a collection, it will return an object with all the coll
Remove a key from Onyx and update the subscribers
retryOperation()
-Handles storage operation failures based on the error type:
+Handles storage operation failures based on the error class (see lib/storage/errors.ts).
+The connection layer (createStore) owns connection/transport recovery; this operation layer owns
+capacity recovery (eviction) so that a given failure is retried by exactly one layer:
-- 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
+- INVALID_DATA: logs an alert and throws (the same data will always fail).
+- TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
+and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
+- CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
+circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
+progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
+- UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
+provider) once so it's visible, then bounded retry without eviction.
broadcastUpdate()
@@ -318,11 +324,17 @@ Remove a key from Onyx and update the subscribers
## retryOperation()
-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
+Handles storage operation failures based on the error class (see lib/storage/errors.ts).
+The connection layer (createStore) owns connection/transport recovery; this operation layer owns
+capacity recovery (eviction) so that a given failure is retried by exactly one layer:
+- INVALID_DATA: logs an alert and throws (the same data will always fail).
+- TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
+ and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
+- CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
+ circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
+ progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
+- UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
+ provider) once so it's visible, then bounded retry without eviction.
**Kind**: global function
diff --git a/lib/CircuitBreaker/AbstractCircuitBreaker.ts b/lib/CircuitBreaker/AbstractCircuitBreaker.ts
new file mode 100644
index 000000000..3ba6feeca
--- /dev/null
+++ b/lib/CircuitBreaker/AbstractCircuitBreaker.ts
@@ -0,0 +1,199 @@
+import StateMachine from '../StateMachine';
+import {CIRCUIT_BREAKER_TRANSITIONS} from './types';
+import type {CircuitBreakerOptions, CircuitBreakerState} from './types';
+
+/**
+ * Generic circuit breaker built on {@link StateMachine}.
+ *
+ * - **closed**: requests are allowed; failures are counted.
+ * - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
+ * - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE
+ * probe request: success means the dependency recovered, so the circuit closes. Failure means it's
+ * still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where
+ * every caller fails loudly when the service hasn't recovered yet.
+ *
+ * Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
+ * friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
+ * combination of those.
+ *
+ * @example
+ * class MyBreaker extends AbstractCircuitBreaker {
+ * private failures = 0;
+ * protected recordFailureInClosed() {
+ * this.failures += 1;
+ * return this.failures >= 3 ? `${this.failures} failures` : null;
+ * }
+ * protected recordSuccessInClosed() { this.failures = 0; }
+ * protected resetFailureState() { this.failures = 0; }
+ * }
+ *
+ * const breaker = new MyBreaker({resetTimeoutMs: 30_000});
+ * if (breaker.isAllowed()) {
+ * try {
+ * doWork();
+ * breaker.recordSuccess();
+ * } catch {
+ * breaker.recordFailure();
+ * }
+ * }
+ */
+abstract class AbstractCircuitBreaker {
+ private machine: StateMachine;
+
+ private openedAt = 0;
+
+ private isProbeInFlight = false;
+
+ private readonly resetTimeoutMs: number;
+
+ private readonly onTrip?: (reason: string) => void;
+
+ private readonly onClose?: () => void;
+
+ constructor(options: CircuitBreakerOptions = {}) {
+ this.resetTimeoutMs = options.resetTimeoutMs ?? 60_000;
+ this.onTrip = options.onTrip;
+ this.onClose = options.onClose;
+ this.machine = new StateMachine('closed', CIRCUIT_BREAKER_TRANSITIONS);
+ }
+
+ /** Record a failure while the circuit is closed. Returns a trip reason when the threshold is exceeded. */
+ protected abstract recordFailureInClosed(): string | null;
+
+ /** Update failure state after a successful request while the circuit is closed. */
+ protected abstract recordSuccessInClosed(): void;
+
+ /** Clear accumulated failure state without changing circuit state. */
+ protected abstract resetFailureState(): void;
+
+ /**
+ * Whether a request may proceed.
+ *
+ * Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
+ * `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
+ * (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
+ * the whole point of half-open: it tests recovery with one request instead of letting a herd of
+ * waiting callers stampede a dependency that may still be down.
+ */
+ isAllowed(): boolean {
+ const currentState = this.getCurrentState();
+
+ if (currentState === 'open') {
+ return false;
+ }
+
+ if (currentState === 'half-open') {
+ if (this.isProbeInFlight) {
+ return false;
+ }
+ this.isProbeInFlight = true;
+ }
+
+ return true;
+ }
+
+ /**
+ * Record a failed request. May open the circuit from closed or half-open.
+ * @returns `true` when the circuit is open after recording (the request must not proceed).
+ */
+ recordFailure(): boolean {
+ if (this.machine.state === 'open') {
+ return true;
+ }
+
+ if (this.machine.state === 'half-open') {
+ this.trip();
+ return true;
+ }
+
+ const reason = this.recordFailureInClosed();
+ if (reason) {
+ this.trip(reason);
+ return true;
+ }
+
+ return false;
+ }
+
+ /** Record a successful request. Closes the circuit from half-open and clears failure counts. */
+ recordSuccess(): void {
+ if (this.machine.state === 'half-open') {
+ this.close();
+ return;
+ }
+
+ if (this.machine.state === 'closed') {
+ this.recordSuccessInClosed();
+ }
+ }
+
+ /**
+ * The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
+ * The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
+ * time a caller queries state after being admitted, that transition has already happened.
+ */
+ peekState(): CircuitBreakerState {
+ return this.machine.state;
+ }
+
+ /**
+ * Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
+ * deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
+ * all state — e.g. between tests or sessions.
+ */
+ protected hardReset(): void {
+ this.machine = new StateMachine('closed', CIRCUIT_BREAKER_TRANSITIONS);
+ this.openedAt = 0;
+ this.isProbeInFlight = false;
+ this.resetFailureState();
+ }
+
+ private getCurrentState(): CircuitBreakerState {
+ this.maybeRecover();
+ return this.machine.state;
+ }
+
+ private trip(reason = ''): void {
+ if (this.machine.state === 'open') {
+ return;
+ }
+
+ this.machine = this.machine.transition('open');
+ this.openedAt = Date.now();
+ this.isProbeInFlight = false;
+ this.resetFailureState();
+ this.onTrip?.(reason);
+ }
+
+ private close(): void {
+ // close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one
+ // legal closing transition — so go through transition() to keep the illegal open → closed jump
+ // an error rather than silently constructing a fresh closed machine.
+ this.machine = this.machine.transition('closed');
+ this.openedAt = 0;
+ this.isProbeInFlight = false;
+ this.resetFailureState();
+ this.onClose?.();
+ }
+
+ /**
+ * Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
+ * (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
+ * the transition simply becomes visible to the next caller after the window. Entering half-open
+ * clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
+ */
+ private maybeRecover(): void {
+ if (this.machine.state !== 'open') {
+ return;
+ }
+
+ if (Date.now() - this.openedAt < this.resetTimeoutMs) {
+ return;
+ }
+
+ this.machine = this.machine.transition('half-open');
+ this.isProbeInFlight = false;
+ }
+}
+
+export default AbstractCircuitBreaker;
diff --git a/lib/CircuitBreaker/types.ts b/lib/CircuitBreaker/types.ts
new file mode 100644
index 000000000..d4076c684
--- /dev/null
+++ b/lib/CircuitBreaker/types.ts
@@ -0,0 +1,43 @@
+/**
+ * States of the circuit breaker.
+ *
+ * - **closed**: normal operation; requests flow and failures are counted.
+ * - **open**: tripped; requests are rejected outright so a known-bad dependency isn't hammered.
+ * - **half-open**: a trial state entered after the open timeout — see {@link CIRCUIT_BREAKER_TRANSITIONS}.
+ */
+type CircuitBreakerState = 'closed' | 'open' | 'half-open';
+
+/**
+ * Legal state transitions. The flow is closed → open → half-open → (closed | open).
+ *
+ * The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
+ * flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
+ * still down, immediately re-admit the full load and re-trip — flapping between open and closed every
+ * window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
+ * ("probe") request:
+ * - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
+ * - probe fails → still broken → transition back to **open** for another timeout window.
+ *
+ * Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
+ * "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
+ * dependency that was just starting to recover.
+ */
+const CIRCUIT_BREAKER_TRANSITIONS = {
+ closed: ['open'],
+ open: ['half-open'],
+ 'half-open': ['closed', 'open'],
+} as const satisfies Record;
+
+type CircuitBreakerOptions = {
+ /** Time in milliseconds the circuit stays open before moving to half-open. */
+ resetTimeoutMs?: number;
+
+ /** Called once each time the circuit opens. */
+ onTrip?: (reason: string) => void;
+
+ /** Called when the circuit closes. */
+ onClose?: () => void;
+};
+
+export type {CircuitBreakerOptions, CircuitBreakerState};
+export {CIRCUIT_BREAKER_TRANSITIONS};
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index c88b2e170..a8c4e510d 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -6,8 +6,9 @@ import * as Logger from './Logger';
import type Onyx from './Onyx';
import cache, {TASK} from './OnyxCache';
import OnyxKeys from './OnyxKeys';
-import * as Str from './Str';
+import StorageCircuitBreaker from './StorageCircuitBreaker';
import Storage from './storage';
+import {StorageErrorClass} from './storage/errors';
import type {
CollectionKeyBase,
ConnectOptions,
@@ -49,26 +50,6 @@ const METHOD = {
CLEAR: 'clear',
} as const;
-// IndexedDB errors that indicate storage capacity issues where eviction can help
-const IDB_STORAGE_ERRORS = [
- 'quotaexceedederror', // Browser storage quota exceeded
-] as const;
-
-// SQLite errors that indicate storage capacity issues where eviction can help
-const SQLITE_STORAGE_ERRORS = [
- 'database or disk is full', // Device storage is full
-] as const;
-
-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;
@@ -425,7 +406,9 @@ function multiGet(keys: CollectionKeyBase[]): Promise