Skip to content
Merged
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
26 changes: 13 additions & 13 deletions lib/CircuitBreaker/AbstractCircuitBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {CircuitBreakerOptions, CircuitBreakerState} from './types';
*
* - **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
* - **halfOpen**: 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.
Expand Down Expand Up @@ -69,10 +69,10 @@ abstract class AbstractCircuitBreaker {
/**
* Whether a request may proceed.
*
* Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
* Returns `false` while open. In halfOpen, 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
* the whole point of halfOpen: it tests recovery with one request instead of letting a herd of
* waiting callers stampede a dependency that may still be down.
*/
isAllowed(): boolean {
Expand All @@ -82,7 +82,7 @@ abstract class AbstractCircuitBreaker {
return false;
}

if (currentState === 'half-open') {
if (currentState === 'halfOpen') {
if (this.isProbeInFlight) {
return false;
}
Expand All @@ -93,15 +93,15 @@ abstract class AbstractCircuitBreaker {
}

/**
* Record a failed request. May open the circuit from closed or half-open.
* Record a failed request. May open the circuit from closed or halfOpen.
* @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') {
if (this.machine.state === 'halfOpen') {
this.trip();
return true;
}
Expand All @@ -115,9 +115,9 @@ abstract class AbstractCircuitBreaker {
return false;
}

/** Record a successful request. Closes the circuit from half-open and clears failure counts. */
/** Record a successful request. Closes the circuit from halfOpen and clears failure counts. */
recordSuccess(): void {
if (this.machine.state === 'half-open') {
if (this.machine.state === 'halfOpen') {
this.close();
return;
}
Expand All @@ -129,7 +129,7 @@ abstract class AbstractCircuitBreaker {

/**
* 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
* The open→halfOpen 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 {
Expand Down Expand Up @@ -166,7 +166,7 @@ abstract class AbstractCircuitBreaker {
}

private close(): void {
// close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one
// close() only ever runs from halfOpen (see recordSuccess), and halfOpen → 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');
Expand All @@ -177,9 +177,9 @@ abstract class AbstractCircuitBreaker {
}

/**
* Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
* Lazily advance open → halfOpen 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
* the transition simply becomes visible to the next caller after the window. Entering halfOpen
* clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
*/
private maybeRecover(): void {
Expand All @@ -191,7 +191,7 @@ abstract class AbstractCircuitBreaker {
return;
}

this.machine = this.machine.transition('half-open');
this.machine = this.machine.transition('halfOpen');
this.isProbeInFlight = false;
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/CircuitBreaker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* - **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';
type CircuitBreakerState = 'closed' | 'open' | 'halfOpen';

/**
* Legal state transitions. The flow is closed → open → half-open → (closed | open).
Expand All @@ -24,8 +24,8 @@ type CircuitBreakerState = 'closed' | 'open' | 'half-open';
*/
const CIRCUIT_BREAKER_TRANSITIONS = {
closed: ['open'],
open: ['half-open'],
'half-open': ['closed', 'open'],
open: ['halfOpen'],
halfOpen: ['closed', 'open'],
} as const satisfies Record<CircuitBreakerState, readonly CircuitBreakerState[]>;

type CircuitBreakerOptions = {
Expand Down
2 changes: 1 addition & 1 deletion lib/StorageCircuitBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class StorageCircuitBreaker extends AbstractCircuitBreaker {
// failure that triggered it must not re-trip the circuit. Let it proceed; the probe's outcome
// is the verdict — recordWriteSuccess (retry landed) closes, recordProbeFailure (retry failed,
// re-entering retryOperation) reopens.
if (this.peekState() === 'half-open') {
if (this.peekState() === 'halfOpen') {
return false;
}
return this.recordFailure();
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/StorageCircuitBreakerTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,12 @@ describe('StorageCircuitBreaker', () => {
StorageCircuitBreaker.recordCapacityFailure();
}
expectAdmissionOpen();
expect(StorageCircuitBreaker.peekState()).toBe('open');

advance(ROLLING_WINDOW_MS);

expectAdmissionHalfOpen();
expect(StorageCircuitBreaker.peekState()).toBe('halfOpen');
});

it('should admit only one capacity retry while half-open', () => {
Expand Down
Loading