react-native-onyx
Version:
State management for React Native
167 lines (166 loc) • 6.63 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const StateMachine_1 = __importDefault(require("../StateMachine"));
const types_1 = require("./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();
* }
* }
*/
class AbstractCircuitBreaker {
constructor(options = {}) {
var _a;
this.openedAt = 0;
this.isProbeInFlight = false;
this.resetTimeoutMs = (_a = options.resetTimeoutMs) !== null && _a !== void 0 ? _a : 60000;
this.onTrip = options.onTrip;
this.onClose = options.onClose;
this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
}
/**
* 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() {
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() {
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() {
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() {
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.
*/
hardReset() {
this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
this.openedAt = 0;
this.isProbeInFlight = false;
this.resetFailureState();
}
getCurrentState() {
this.maybeRecover();
return this.machine.state;
}
trip(reason = '') {
var _a;
if (this.machine.state === 'open') {
return;
}
this.machine = this.machine.transition('open');
this.openedAt = Date.now();
this.isProbeInFlight = false;
this.resetFailureState();
(_a = this.onTrip) === null || _a === void 0 ? void 0 : _a.call(this, reason);
}
close() {
var _a;
// 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();
(_a = this.onClose) === null || _a === void 0 ? void 0 : _a.call(this);
}
/**
* 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.
*/
maybeRecover() {
if (this.machine.state !== 'open') {
return;
}
if (Date.now() - this.openedAt < this.resetTimeoutMs) {
return;
}
this.machine = this.machine.transition('half-open');
this.isProbeInFlight = false;
}
}
exports.default = AbstractCircuitBreaker;