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.
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
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:
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.
Related
- Client Fallback Strategies — up-link: the section covering degradation and fallback behaviour
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Retryable vs Non-Retryable Errors — the classification that decides what counts as a failure
- Configuring Exponential Backoff for 5xx Errors — retry behaviour before the breaker opens
- Safe Retries for Non-Idempotent POST — bounding retries on write operations