Circuit Breaker Thresholds for HTTP Clients

This page belongs to Client Fallback Strategies in the Error Contracts & Resilience Mapping reference. A breaker with default thresholds is worse than none: it either never trips when a dependency is failing, or trips constantly on normal error rates and turns a partial degradation into a total one. The numbers are the design.

The decision trigger

Symptom Cause
The breaker never opens during a real outage threshold too high, or window too long
The breaker opens during normal operation 4xx responses counted as failures
One bad endpoint takes the whole client offline breaker scoped to the host, not the operation
A recovering service is knocked over again all clients probe simultaneously — no jitter
The breaker opens after one failure at 3 a.m. no minimum request volume
Recovery takes minutes after the service is healthy open duration far longer than the outage

Spec snippet: the five parameters

// breaker-config.ts — every parameter, with defaults that actually work
export interface BreakerConfig {
  /** Fraction of failed calls that opens the circuit. 0.5 = half of all calls. */
  failureRateThreshold: number;
  /** Minimum calls in the window before the rate is evaluated at all. */
  minimumThroughput: number;
  /** Rolling window over which the rate is computed. */
  windowMs: number;
  /** How long the circuit stays open before a probe is allowed. */
  openDurationMs: number;
  /** Consecutive successful probes required to close it again. */
  halfOpenSuccesses: number;
}

export const DEFAULTS: BreakerConfig = {
  failureRateThreshold: 0.5,    // half the calls failing is unambiguous
  minimumThroughput: 20,        // never judge on fewer than 20 calls
  windowMs: 10_000,             // 10s rolling window
  openDurationMs: 5_000,        // probe again after 5s (plus jitter)
  halfOpenSuccesses: 3,         // three good probes before full traffic
};

minimumThroughput is the parameter most often missing, and its absence produces the classic overnight failure: at 3 a.m. the endpoint receives two calls, one fails, the rate is 50%, and the breaker opens for a service that is perfectly healthy.

Circuit breaker states and transitions Closed lets traffic through and counts failures. When the failure rate exceeds the threshold with enough volume, the circuit opens and fails fast. After the open duration it moves to half-open and allows limited probes. Enough successes close it; one failure reopens it. CLOSED traffic flows failures counted rate > 50% over ≥ 20 calls OPEN fail fast, no calls fallback served after openDuration + jitter HALF-OPEN limited probes only one at a time 3 successes 1 failure while OPEN serve cache, degrade, or 503

Step-by-step

Step 1 — Decide what counts as a failure

// failure-classification.ts — what trips the breaker, and what does not
export function countsAsFailure(outcome: CallOutcome): boolean {
  if (outcome.kind === "transport") return true;          // connect/read timeout, reset
  const s = outcome.status;

  if (s >= 500) return true;                              // the dependency is unwell
  if (s === 429) return true;                             // systemic: we are over quota
  if (s === 401 || s === 403) return true;                // systemic: credentials wrong

  // 400, 404, 409, 412, 422 — THIS request was wrong. Not the dependency's health.
  return false;
}

Counting ordinary 4xx responses is the most common misconfiguration. A single caller sending malformed payloads should not open a shared circuit and deny service to every other caller — and it will, because a breaker is usually a process-wide singleton. 401 and 429 are the deliberate exceptions: both mean every request from this client will fail until something changes, which is exactly the condition a breaker exists to detect. The underlying classification is the same one in Retryable vs Non-Retryable Errors.

Step 2 — Scope the breaker to what fails together

Scope Trips on Use when
per host/connection DNS, TLS, connection refused the whole dependency is unreachable
per operation one endpoint’s 5xx rate endpoints have independent backends
per tenant + operation one tenant’s shard failing multi-tenant with per-tenant partitions
global anything almost never — too coarse

Per-operation is the usual right answer. A slow report-generation endpoint should not disable the login endpoint of the same service, and in practice they often fail independently because they touch different datastores.

Step 3 — Implement with jitter on recovery

// breaker.ts — a rolling-window breaker with jittered recovery
export class CircuitBreaker {
  private state: "closed" | "open" | "half_open" = "closed";
  private calls: Array<{ at: number; failed: boolean }> = [];
  private openedUntil = 0;
  private probeSuccesses = 0;
  private probeInFlight = false;

  constructor(private readonly cfg: BreakerConfig = DEFAULTS,
              private readonly now = () => Date.now()) {}

  async run<T>(operation: () => Promise<T>, fallback: () => T): Promise<T> {
    const t = this.now();

    if (this.state === "open") {
      if (t < this.openedUntil) return fallback();          // fail fast
      this.state = "half_open";
      this.probeSuccesses = 0;
    }

    if (this.state === "half_open") {
      if (this.probeInFlight) return fallback();            // one probe at a time
      this.probeInFlight = true;
    }

    try {
      const result = await operation();
      this.record(false, t);
      return result;
    } catch (e) {
      this.record(true, t);
      throw e;
    } finally {
      if (this.state === "half_open") this.probeInFlight = false;
    }
  }

  private record(failed: boolean, t: number) {
    this.calls.push({ at: t, failed });
    this.calls = this.calls.filter(c => c.at > t - this.cfg.windowMs);

    if (this.state === "half_open") {
      if (failed) return this.trip(t);
      if (++this.probeSuccesses >= this.cfg.halfOpenSuccesses) {
        this.state = "closed";
        this.calls = [];
      }
      return;
    }

    if (this.calls.length < this.cfg.minimumThroughput) return;   // not enough evidence
    const rate = this.calls.filter(c => c.failed).length / this.calls.length;
    if (rate >= this.cfg.failureRateThreshold) this.trip(t);
  }

  private trip(t: number) {
    this.state = "open";
    // Jitter prevents a fleet of clients probing in lockstep.
    const jitter = Math.floor(Math.random() * this.cfg.openDurationMs * 0.3);
    this.openedUntil = t + this.cfg.openDurationMs + jitter;
    this.probeSuccesses = 0;
  }
}

Two properties matter beyond the thresholds. Only one probe is allowed in half-open, so a recovering service receives a trickle rather than the full fleet at once. And the open duration carries jitter, so a hundred client instances that tripped together do not all probe at the same millisecond — the thundering-herd failure that turns a brief outage into a repeating one.

Step 4 — Choose thresholds from the dependency’s normal behaviour

Threshold placement against normal error rates A dependency's normal error rate fluctuates around three percent with occasional spikes to fifteen percent. A threshold at fifty percent trips only during the real outage, while a threshold at ten percent trips repeatedly on ordinary noise. time error rate 100% 0% threshold 50% — trips once, correctly threshold 10% — trips on ordinary noise real outage normal variance

Start from the dependency’s observed error rate over a month, not from a blog post’s default. If a service normally errs at 3% with spikes to 15%, a 50% threshold distinguishes an outage cleanly, while a 10% threshold guarantees false trips. Where the normal rate is genuinely high, the breaker is the wrong tool — fix the error rate or use a bulkhead to bound the blast radius instead.

Thresholds are only half the tuning; the other half is what the client does while the circuit is open:

Fallback options while a circuit is open Serving cached data, degrading the feature, queueing the write or failing fast each suit different operations, and each has a distinct correctness cost. failing fast is not a strategy on its own serve stale from cache reads whose age is tolerable label the response as stale degrade the feature optional enrichment data render without it, log the gap queue the write idempotent, deferrable work needs a key to deduplicate later fail fast with 503 everything else include Retry-After from the openduration the choice belongs to the operation, not to the breaker

Standard compliance

Element Standard Clause Note
503 Service Unavailable RFC 9110 §15.6.4 What to return when the circuit is open
Retry-After RFC 9110 §10.2.3 Advertise the remaining open duration
429 semantics RFC 6585 §4 Systemic — should count toward the breaker
504 Gateway Timeout RFC 9110 §15.6.5 Counts as a dependency failure
Problem body RFC 9457 §3 Explain the open circuit to your own callers

If your service fails fast because a downstream circuit is open, return 503 with a Retry-After derived from the remaining open duration. Returning 500 tells your callers you are broken, when in fact you are deliberately shedding load for a dependency — a distinction that matters to their breakers.

SDK / codegen downstream effect

  // Generated client, wrapped once with a per-operation breaker
+ const breakers = new Map<string, CircuitBreaker>();
+
+ function guarded<T>(op: string, call: () => Promise<T>, fallback: () => T) {
+   const breaker = breakers.get(op) ?? breakers.set(op, new CircuitBreaker()).get(op)!;
+   return breaker.run(call, fallback);
+ }
+
+ export const invoices = {
+   get: (id: string) =>
+     guarded("invoices.get",
+       () => api.getInvoice({ invoiceId: id }),
+       () => cache.get(`invoice:${id}`) ?? { stale: true }),
+ };

Keying breakers by operationId gives per-endpoint scoping for free — one more reason the naming discipline in Naming Operation IDs for Readable SDK Methods pays off beyond readability.

Common mistakes

Mistake Correct approach
Counting all 4xx responses as failures Count 5xx, transport errors, 429 and 401 only
No minimum request volume Require ~20 calls before evaluating the rate
One breaker for the entire host Scope per operation, with a host-level breaker above
No jitter on the open duration Jitter so clients do not probe in lockstep
Full traffic on the first successful probe Require several successes, one probe at a time
Open duration of minutes Seconds to tens of seconds for internal services
Returning 500 while the circuit is open Return 503 with Retry-After
Thresholds copied from a default config Derive them from the dependency’s observed error rate

FAQ

Should a 4xx response trip a circuit breaker?

Almost never. A 400, 404, 409 or 422 says that one specific request was wrong — the dependency evaluated it correctly and rejected it, which is evidence of health, not sickness. Counting them means a single caller sending malformed payloads can open a process-wide circuit and deny service to every other caller, which is a spectacular way to convert one team’s bug into an incident. The two exceptions are 429 and 401: both indicate a systemic condition where every request from this client will fail until quota resets or credentials are fixed, and both are better handled by failing fast than by hammering.

How long should the circuit stay open?

Long enough that a struggling dependency gets breathing room, short enough that recovery is not delayed by your own configuration. For internal services, five to thirty seconds covers most cases: dependencies usually recover on that timescale, and a longer window means your users see degraded behaviour well after the problem is fixed. Always add jitter — twenty to thirty percent is plenty — because otherwise every client instance that tripped together will probe together, and a service that has just come back up receives a synchronised wave that knocks it over again.

Is a circuit breaker per host, per endpoint or per operation?

Scope it to whatever fails together, which is usually per operation. Endpoints within one service frequently have independent failure modes — a search endpoint backed by a struggling index, a health endpoint that is fine — and a host-wide breaker forces you to treat them as one. Keep a host-level breaker as well for connection-level failures (DNS, TLS, connection refused) that genuinely affect every request, and let the two nest: the host breaker short-circuits everything, the operation breakers handle partial degradation. Global breakers are almost always too coarse to be useful.