










The third-party email API normally responds in 80 ms. Today it is responding in 8 seconds, when it responds at all. Your service, which calls the email API on every signup, is now holding 200 requests open waiting on a doomed network call. The Node.js process exhausts its outbound HTTP agent pool. New incoming requests pile up behind the stuck ones. CPU is fine, memory is fine, the application is just standing in line. From the outside it looks like your service is broken.
The fix is the circuit breaker pattern. When a downstream service starts failing, your service stops calling it for a while, returns a fast fallback, and gives the dependency time to recover. About 50 lines of Node.js. The bar to add it is whether the dependency has ever had a multi-minute outage. (Spoiler: every dependency has.)
A circuit breaker is a tiny state machine wrapping a function:
[ Closed ] ── failure rate too high ──> [ Open ]
▲ │
│ │ cooldown elapsed
│ probe ok ▼
└──────────── [ Half-Open ] <──────────┘
│
│ probe failed
▼
[ Open ]
The point of half-open is that you do not flip from “everything blocked” to “everything allowed.” You let one or two requests through to test the water. If the dependency is still broken, you do not flood it with retries.
type State = 'closed' | 'open' | 'half-open';
interface BreakerOptions {
failureThreshold: number; // e.g., 0.5 (50% errors)
windowSize: number; // e.g., 20 calls (sample size)
cooldownMs: number; // e.g., 30_000 (wait before half-open probe)
timeoutMs: number; // e.g., 3_000 (per-call timeout)
}
export class CircuitBreaker<T extends (...a: any[]) => Promise<any>> {
private state: State = 'closed';
private results: boolean[] = []; // recent successes (true) / failures (false)
private nextAttemptAt = 0;
constructor(private readonly fn: T, private readonly opts: BreakerOptions) {}
async call(...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
if (this.state === 'open') {
if (Date.now() < this.nextAttemptAt) throw new BreakerOpenError();
this.state = 'half-open';
}
try {
const result = await this.withTimeout(this.fn(...args));
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private async withTimeout<R>(p: Promise<R>): Promise<R> {
return Promise.race([
p,
new Promise<R>((_, reject) =>
setTimeout(() => reject(new TimeoutError()), this.opts.timeoutMs)),
]);
}
private onSuccess() {
this.record(true);
if (this.state === 'half-open') this.state = 'closed';
}
private onFailure() {
this.record(false);
if (this.state === 'half-open' || this.shouldOpen()) {
this.state = 'open';
this.nextAttemptAt = Date.now() + this.opts.cooldownMs;
}
}
private record(ok: boolean) {
this.results.push(ok);
if (this.results.length > this.opts.windowSize) this.results.shift();
}
private shouldOpen(): boolean {
if (this.results.length < this.opts.windowSize) return false;
const failures = this.results.filter(r => !r).length;
return failures / this.results.length >= this.opts.failureThreshold;
}
}
export class BreakerOpenError extends Error { constructor() { super('breaker open'); } }
export class TimeoutError extends Error { constructor() { super('timed out'); } }
That is the entire breaker. Three states, a sliding window of recent results, a cooldown clock, and a timeout. About 50 lines.
import { CircuitBreaker, BreakerOpenError } from './circuit-breaker';
import { sendEmailViaProvider } from './email';
const emailBreaker = new CircuitBreaker(sendEmailViaProvider, {
failureThreshold: 0.5,
windowSize: 20,
cooldownMs: 30_000,
timeoutMs: 3_000,
});
export async function sendWelcomeEmail(userId: string) {
try {
await emailBreaker.call({ userId, template: 'welcome' });
} catch (err) {
if (err instanceof BreakerOpenError) {
// Fallback: queue for retry instead of dropping or blocking the request.
await queueEmailForRetry({ userId, template: 'welcome' });
return;
}
throw err;
}
}
Notice the fallback. A breaker without a fallback is just a faster way to fail. The shapes of useful fallbacks:
stale=true flag.The fallback is what turns “circuit breaker tripped” from “this feature is broken” into “this feature is gracefully degraded.”
Default settings that work for most HTTP dependencies:
failureThreshold: 0.5, open when half the recent calls failed.windowSize: 20, sample size. Smaller windows are jumpy; larger windows are slow to react.cooldownMs: 30_000, 30 seconds open before the next probe. Long enough that a downstream blip clears, short enough that recovery is quick.timeoutMs: < the slowest legitimate response, the timeout is what causes calls to count as failures fast. Without it, slow calls do not trip the breaker.A surprising one: timeoutMs is often the most important. A downstream that responds in 8s instead of 80ms is, for your purposes, broken, but unless you time it out, it never registers as a failure. A 3-second timeout against a service that should respond in <500ms is a reasonable default.
A breaker is for calls that can fail. A few things should not go through one:
The right targets: third-party HTTP calls, downstream microservices, queue producers, anything where “we’ll try again in 30 seconds” is a sensible response to current failures.
A breaker stops calls from reaching a stuck dependency. A bulkhead limits the concurrency of calls before they reach the breaker. They are complementary: the breaker says “stop calling,” the bulkhead says “no more than 50 calls at a time.”
import pLimit from 'p-limit';
const limit = pLimit(50);
await limit(() => emailBreaker.call(...));
p-limit is enough for in-process bulkheads. With both in place, a single bad dependency cannot consume more than 50 of your worker slots, and after a few failures it stops consuming any.
A breaker without metrics is invisible until it trips at 3 a.m. and nobody knows why. The four metrics worth emitting:
failures / windowSize. Tells you how close to the threshold you are.Alert on state == 'open' for 5 minutes. That is the “something is genuinely broken downstream” signal.
If you do not want to write the 50 lines, there are libraries:
For Java, resilience4j is the standard. Spring Cloud has built-in integration. The patterns transfer one-to-one.
I generally use opossum or cockatiel in production for the metrics and tested fallback semantics. The 50-line version is for understanding.
Two cases.
The dependency is fundamentally broken. A breaker recovers when the dependency does. If the third-party API is down for two days, the breaker just keeps tripping and back-off does not help. You need a queue, manual ops, and an SLA conversation.
The fallback is more expensive than the call. Sometimes “degrade gracefully” is more compute than “let the user wait.” Profile both paths before adding a breaker.
For everything else (most external dependencies, most service-to-service calls) a breaker is a 50-line change that prevents one of the most common cascading failure modes.
A circuit breaker is one of the highest-leverage reliability investments you can make in any service that calls a service it does not own. It costs ~50 lines, prevents a stuck dependency from cascading into your service, and turns “we are down because Stripe is down” into “Stripe is down and our retry queue is filling; we will catch up in 5 minutes.”
Pick failure thresholds, cooldown, timeout, and a fallback that makes sense for the call. Wrap third-party calls and downstream services. Emit four metrics. Alert on the breaker being open. The next time a dependency has a bad afternoon, you will not have one.
The kind of resilience engineering that prevents one slow downstream from taking down a whole service (circuit breakers, bulkheads, fallbacks, the metrics that prove they work) is the kind of long-haul backend work that decides how a system behaves on its worst days. It is the kind of engineering Yojji’s teams build into the production systems they ship for clients.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and microservices, including the reliability engineering that decides whether your incident is a blip or a saga.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。