abhigyanpatwari/GitNexus · error · CircuitOpenError

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

Error message

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

What it means

Thrown by CircuitBreaker.check() when the breaker is 'half-open' (cooldown elapsed, probing recovery) but the single probe permit is already held by another in-flight call. Half-open admits exactly one concurrent probe to prevent the thundering-herd that would defeat fail-fast during recovery; concurrent callers get this error with retryAfterMs = halfOpenRetryAfterMs (default 1000ms), distinct from the remaining-cooldown value of the open-state error.

Source

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

   * 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;
    this.consecutiveFailures = 0;
    this.state = 'closed';
    this.openedAt = null;
  }

  recordFailure(): void {
    this.probeInFlight = false;
    this.consecutiveFailures += 1;
    if (this.state === 'half-open' || this.consecutiveFailures >= this.failureThreshold) {
      this.state = 'open';

View on GitHub (pinned to d540b00184)

Solutions

  1. Retry after halfOpenRetryAfterMs (default 1000ms) — the probe will have resolved and either closed or re-opened the breaker
  2. Raise halfOpenRetryAfterMs in CircuitBreakerOptions if the protected op is long-running (LLM streaming, large uploads) so the suggested wait matches reality
  3. Serialize calls to the recovering dependency until the probe resolves, rather than firing concurrent retries
  4. Avoid retry storms: do not retry in a tight loop — the error's retryAfterMs is the floor

Example fix

// before — concurrent callers all retry at once, re-stampeding the probe
async function call(breaker, op) {
  breaker.check();
  return op();
}

// after — back off by halfOpenRetryAfterMs when the probe is busy
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

import { getBreaker } from 'gitnexus-shared/src/integrations/circuit-breaker.js';
const breaker = getBreaker('my-key');
// getState() returns 'half-open' if cooldown elapsed; isProbeInFlight() tells if a probe is outstanding
if (breaker.getState() === 'half-open' && breaker.isProbeInFlight()) {
  console.log('probe in flight; back off ~1s');
}

Type guard

import { CircuitOpenError } from 'gitnexus-shared/src/integrations/circuit-breaker.js';
function isHalfOpenBlocked(e: unknown): e is CircuitOpenError {
  return e instanceof CircuitOpenError; // distinguish from open-cooldown by retryAfterMs magnitude
}

Try / catch

try {
  breaker.check();
} catch (e) {
  if (e instanceof CircuitOpenError) {
    // halfOpenRetryAfterMs (default 1s) — back off, don't stampede
    await new Promise(r => setTimeout(r, e.retryAfterMs));
    return retry();
  }
  throw e;
}

Prevention

When it happens

Trigger: Two or more concurrent calls to breaker.check()/resilientFetch against the same breaker key while state==='half-open' and probeInFlight===true. Also fires for the caller that observed the Open→Half-Open transition but lost the microtask race to a peer that grabbed the permit first.

Common situations: Recovery from an outage: cooldown elapsed, the first probe is a slow operation (LLM stream, large upload), and concurrent UI/API requests pile up behind it. Spikes during partial recovery of a flaky backend where only one request is allowed through to test health.

Related errors


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