abhigyanpatwari/GitNexus · error · CircuitOpenError

Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs

Error message

Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs / 1000)}s

What it means

Thrown by CircuitBreaker.check() when the breaker is in the 'open' state and the configured cooldown (default 30000ms) has not elapsed since it tripped. After failureThreshold (default 3) consecutive recordFailure() calls transition Closed→Open, the breaker fast-fails subsequent calls to protect the failing dependency instead of queuing load. The error's retryAfterMs field carries the remaining cooldown so callers can schedule a precise retry.

Source

Thrown at gitnexus-shared/src/integrations/circuit-breaker.ts:156

   *      remaining cooldown.
   *   2. Open with cooldown elapsed AND a probe is already in flight
   *      (race: another caller transitioned to half-open and grabbed
   *      the permit on a microtask before us) → throws with
   *      `halfOpenRetryAfterMs`.
   *   3. Half-Open with probe in flight → throws with `halfOpenRetryAfterMs`.
   *
   * **Pairing invariant**: every successful return from `check()` MUST
   * be paired with exactly one `recordSuccess` / `recordFailure` /
   * `recordNeutral` on every code path including thrown exceptions.
   * Failing to pair leaves the probe permit consumed forever and
   * wedges the breaker. See file-header JSDoc for the canonical
   * try/finally pattern.
   */
  check(): void {
    if (this.state === 'open' && this.openedAt !== null) {
      const elapsed = this.now() - this.openedAt;
      if (elapsed < this.cooldownMs) {
        throw new CircuitOpenError(this.cooldownMs - elapsed, this.key);
      }
      // Cooldown elapsed — transition to Half-Open. The very next
      // `probeInFlight` check below decides whether THIS caller gets
      // the permit or hits the gate.
      this.state = 'half-open';
    }

    if (this.state === 'half-open') {
      if (this.probeInFlight) {
        throw new CircuitOpenError(this.halfOpenRetryAfterMs, this.key);
      }
      this.probeInFlight = true;
    }
    // Closed state falls through silently.
  }

  recordSuccess(): void {
    this.probeInFlight = false;

View on GitHub (pinned to d540b00184)

Solutions

  1. Wait for the cooldown (default 30s) to elapse; the breaker self-transitions to half-open on the next check()
  2. Verify the protected dependency is actually reachable — restart `gitnexus serve` or check the remote endpoint health
  3. In tests, call __resetBreakerRegistry__() in beforeEach to prevent breaker state leaking across cases
  4. Tune failureThreshold / cooldownMs via CircuitBreakerOptions if the defaults are too aggressive for your dependency's recovery profile

Example fix

// before — breaker trips, callers see CircuitOpenError immediately
const breaker = getBreaker('my-api');
breaker.check(); // throws if 3 prior failures within 30s

// after — honor retryAfterMs before retrying
try {
  breaker.check();
} catch (e) {
  if (e instanceof CircuitOpenError) {
    await new Promise(r => setTimeout(r, e.retryAfterMs));
    breaker.check();
  } else throw e;
}
Defensive patterns

Strategy: retry

Validate before calling

// Inspect breaker state without consuming the probe permit
import { getBreaker } from 'gitnexus-shared/src/integrations/circuit-breaker.js';
const breaker = getBreaker('my-key');
const state = breaker.getState(); // 'closed' | 'open' | 'half-open'
if (state === 'open') {
  const openedAt = breaker.getOpenedAt();
  const remaining = breaker.getCooldownMs() - (Date.now() - (openedAt ?? 0));
  console.log(`breaker open, ~${Math.ceil(remaining/1000)}s left`);
}

Type guard

import { CircuitOpenError } from 'gitnexus-shared/src/integrations/circuit-breaker.js';
function isCircuitOpen(e: unknown): e is CircuitOpenError {
  return e instanceof CircuitOpenError;
}

Try / catch

try {
  breaker.check();
  // ... protected op, paired with recordSuccess/recordFailure/recordNeutral
} catch (e) {
  if (e instanceof CircuitOpenError) {
    // e.retryAfterMs is the precise wait; schedule a retry, don't busy-loop
    await new Promise(r => setTimeout(r, e.retryAfterMs));
    return retry();
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling breaker.check() (directly or via resilientFetch) when state==='open', openedAt is non-null, and now()-openedAt < cooldownMs. Concretely: any resilientFetch against a breaker key that has already recorded 3 failures (default) within the cooldown window throws synchronously before fetch is even invoked.

Common situations: The protected backend or registry is down or returning 5xx/429 repeatedly — three failures tripped the breaker, so every call for the next 30s is rejected without hitting the network. Typical during local dev when `gitnexus serve` crashed, or against a flaky remote endpoint under load.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12). Data as JSON: /api/errors/6fd7df14c3dd39a4. Report an issue: GitHub.