Wzorzec Circuit Breaker i izolacja Bulkhead
Powstrzymuj kaskadowe awarie, rozłączając obwody i izolując pule zasobów dla poszczególnych zależności.
Wzorzec Circuit Breaker i izolacja Bulkhead to bezpłatna lekcja Node.js Backend Development Bootcamp na CoddyKit. To lekcja 2 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej Node.js Backend Development Bootcamp, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs Node.js Backend Development Bootcamp zawiera 4 lekcji w sumie.
Części tej lekcji nie zostały jeszcze przetłumaczone i są wyświetlane po angielsku.
Why Cascading Failures Happen
In a microservice backend, your Node.js API often depends on downstream services: a payments provider, an inventory service, a database. When one dependency becomes slow (not even down — just slow), every request that touches it piles up.
- Each pending request holds an event-loop slot, a socket, and memory.
- Callers retry, multiplying load on the already-struggling dependency.
- Your healthy endpoints starve because the process is saturated waiting on the sick one.
This is a cascading failure: one slow dependency drags down the entire service. Two patterns fight this — the circuit breaker (stop calling a failing dependency) and the bulkhead (cap how many resources any one dependency can consume).
The Naive Retry Makes It Worse
A common first instinct is to wrap a flaky call in a retry loop. But blind retries amplify a partial outage into a full one — you triple the traffic at the exact moment the dependency can least handle it.
Below, a fragile downstream is hammered. Notice how retries turn 1 logical request into many physical calls.
async function callDependency(attempt) {
// Simulate a dependency that fails 70% of the time
if (Math.random() < 0.7) {
throw new Error('dependency timeout');
}
return 'ok';
}
async function withBlindRetry(maxRetries) {
let physicalCalls = 0;
for (let i = 0; i <= maxRetries; i++) {
physicalCalls++;
try {
const res = await callDependency(i);
console.log(`Success after ${physicalCalls} physical call(s)`);
return res;
} catch (e) {
console.log(`Attempt ${i + 1} failed: ${e.message}`);
}
}
console.log(`Gave up after ${physicalCalls} physical calls`);
}
withBlindRetry(3);The Circuit Breaker States
A circuit breaker wraps a call and tracks its health. It is a small state machine with three states:
- CLOSED — calls flow through normally. Failures are counted.
- OPEN — too many recent failures. Calls are rejected immediately without touching the dependency (fail fast).
- HALF_OPEN — after a cooldown, a few trial calls are allowed. If they succeed, go back to CLOSED; if they fail, snap back to OPEN.
The key insight: when OPEN, you stop wasting resources on a dependency that is already failing. This gives it room to recover and keeps your event loop free.
A Minimal Circuit Breaker
Here is a self-contained breaker. It opens after a failure threshold, rejects fast while OPEN, then probes once the reset timeout passes. This is the core logic every production library implements.
class CircuitBreaker {
constructor(fn, { threshold = 3, resetMs = 5000 } = {}) {
this.fn = fn;
this.threshold = threshold;
this.resetMs = resetMs;
this.failures = 0;
this.state = 'CLOSED';
this.nextTry = 0;
}
async exec(...args) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextTry) {
throw new Error('Circuit OPEN - failing fast');
}
this.state = 'HALF_OPEN';
}
try {
const result = await this.fn(...args);
this.failures = 0;
this.state = 'CLOSED';
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
this.nextTry = Date.now() + this.resetMs;
}
throw err;
}
}
}
let n = 0;
const flaky = async () => { n++; if (n <= 5) throw new Error('boom'); return 'ok'; };
const breaker = new CircuitBreaker(flaky, { threshold: 3, resetMs: 1000 });
(async () => {
for (let i = 0; i < 4; i++) {
try { console.log(await breaker.exec()); }
catch (e) { console.log(`call ${i}: ${e.message} [state=${breaker.state}]`); }
}
})();Fail Fast Beats Slow Failure
The real win of OPEN state is latency. A timeout might be 10 seconds; a tripped breaker rejects in microseconds. Under load this is the difference between survival and collapse.
Compare the cost of 100 requests hitting a 2-second-timeout dependency versus a breaker that rejects instantly once tripped.
function estimate(reqs, timeoutMs, openAfter) {
let totalMs = 0;
for (let i = 0; i < reqs; i++) {
if (i < openAfter) {
totalMs += timeoutMs; // these waited for the full timeout
} else {
totalMs += 0.01; // breaker rejected instantly
}
}
return totalMs;
}
const reqs = 100, timeout = 2000, openAfter = 5;
const withBreaker = estimate(reqs, timeout, openAfter);
const noBreaker = reqs * timeout;
console.log(`No breaker: ${noBreaker} ms of blocked time`);
console.log(`With breaker: ${withBreaker.toFixed(2)} ms of blocked time`);
console.log(`Saved: ${(noBreaker - withBreaker).toFixed(0)} ms`);Using Opossum in Production
Don't ship a hand-rolled breaker. The de-facto Node.js library is opossum. It adds rolling-window error rates, a fallback, per-call timeouts, and rich events/metrics.
timeout— abort a call that hangs too long.errorThresholdPercentage— trip when this % of calls in the window fail.resetTimeout— how long to stay OPEN before probing.
This snippet needs the opossum package and a network call, so it is illustrative, not runnable here.
const CircuitBreaker = require('opossum');
async function getInventory(sku) {
const res = await fetch(`http://inventory.internal/items/${sku}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const breaker = new CircuitBreaker(getInventory, {
timeout: 3000, // fail a call after 3s
errorThresholdPercentage: 50, // open at 50% failures in the window
resetTimeout: 10000, // try again after 10s
rollingCountTimeout: 10000, // 10s stats window
});
// Serve stale/cached data instead of erroring out
breaker.fallback((sku) => ({ sku, stock: 'unknown', cached: true }));
breaker.on('open', () => console.warn('inventory breaker OPEN'));
breaker.on('halfOpen', () => console.info('inventory breaker probing'));
module.exports = (sku) => breaker.fire(sku);Fallbacks: Degrade, Don't Die
A tripped breaker should usually return something useful rather than a 500. This is graceful degradation:
- Serve a cached or stale value.
- Return a safe default (empty recommendations, last-known price).
- Queue the work for later (write to a buffer, process when the dependency recovers).
Pick the fallback per dependency based on business rules. A pricing service should fail closed (block the sale); a recommendations service should fail open (show nothing) so the page still loads.
The Bulkhead Pattern
A breaker stops calls after things go wrong. A bulkhead prevents one dependency from ever monopolizing resources in the first place. The name comes from ships: watertight compartments so one flooded section doesn't sink the whole vessel.
In a backend, you give each dependency its own bounded pool:
- A capped number of concurrent in-flight calls.
- A separate connection pool per database or HTTP target.
- Optionally a bounded queue; overflow is rejected fast.
So if the payments API hangs, at most N slots are stuck there — your inventory and auth calls keep their own slots and stay healthy.
Implementing a Concurrency Bulkhead
The simplest bulkhead is a concurrency limiter (a semaphore). It caps how many calls to a given dependency run at once and rejects (or queues) the rest, so a slow dependency can only ever tie up maxConcurrent slots.
class Bulkhead {
constructor(maxConcurrent, maxQueue = 0) {
this.max = maxConcurrent;
this.maxQueue = maxQueue;
this.active = 0;
this.queue = [];
}
async run(task) {
if (this.active >= this.max) {
if (this.queue.length >= this.maxQueue) {
throw new Error('Bulkhead full - rejected');
}
await new Promise((resolve) => this.queue.push(resolve));
}
this.active++;
try {
return await task();
} finally {
this.active--;
const next = this.queue.shift();
if (next) next();
}
}
}
const bh = new Bulkhead(2, 2);
const slow = (id) => () => new Promise((r) => setTimeout(() => { console.log('done', id); r(id); }, 50));
(async () => {
const results = await Promise.allSettled(
[1, 2, 3, 4, 5, 6].map((id) => bh.run(slow(id)))
);
results.forEach((r, i) =>
console.log(`task ${i + 1}: ${r.status}${r.reason ? ' - ' + r.reason.message : ''}`)
);
})();Per-Dependency Connection Pools
The most common real-world bulkhead is the HTTP connection pool. Node's default global agent shares sockets across all targets. Instead, give each downstream its own Agent with a capped maxSockets. A hung dependency can only exhaust its own pool.
This uses the http module's Agent and a live socket, so treat it as a configuration pattern rather than a judge-runnable program.
const http = require('http');
// One isolated pool per downstream service
const paymentsAgent = new http.Agent({
keepAlive: true,
maxSockets: 10, // at most 10 concurrent connections to payments
maxFreeSockets: 5,
});
const inventoryAgent = new http.Agent({
keepAlive: true,
maxSockets: 20, // inventory gets its own, independent budget
});
function callPayments(path) {
return new Promise((resolve, reject) => {
const req = http.request(
{ host: 'payments.internal', path, agent: paymentsAgent, timeout: 3000 },
(res) => { res.resume(); res.on('end', resolve); }
);
req.on('timeout', () => req.destroy(new Error('payments timeout')));
req.on('error', reject);
req.end();
});
}
module.exports = { callPayments, paymentsAgent, inventoryAgent };Combining Breaker + Bulkhead
Production resilience layers both patterns per dependency:
- Bulkhead bounds concurrency so a slow dependency can't saturate the process.
- Timeout ensures no single call hangs forever.
- Circuit breaker stops calling once the failure rate is high.
- Fallback returns a degraded-but-useful response.
Order matters: wrap the raw call with a timeout, run it through the bulkhead, and put the breaker on the outside so it short-circuits before you even acquire a bulkhead slot. With opossum, the breaker's timeout plus its capacity/volume options can cover most of this, but explicit per-dependency pools give you the strongest isolation.
// Compose: breaker(bulkhead(timeout(call)))
function withTimeout(fn, ms) {
return (...args) => Promise.race([
fn(...args),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)),
]);
}
function resilient(rawCall, { bulkhead, breaker, timeoutMs }) {
const timed = withTimeout(rawCall, timeoutMs);
// breaker on the OUTSIDE: it can fail fast before we ever take a bulkhead slot
return (...args) => breaker.exec(() => bulkhead.run(() => timed(...args)));
}
async function demo() {
await withTimeout(() => Promise.resolve('fast'), 50)().then(console.log);
try { await withTimeout(() => new Promise(() => {}), 30)(); }
catch (e) { console.log('slow call ->', e.message); }
console.log('Compose order: breaker -> bulkhead -> timeout -> rawCall');
}
demo();Quick Check
Your payments dependency starts responding slowly (3-8s) but not erroring. Each request to it holds an event-loop slot and a socket. Which combination best prevents this one slow dependency from taking down your whole Node.js service?
Recap
To stop cascading failures in a Node.js backend, isolate and guard each dependency:
- Cascading failure starts with a slow dependency, not just a dead one — pending calls exhaust sockets and the event loop.
- Circuit breaker (CLOSED / OPEN / HALF_OPEN) fails fast once the recent error rate is high, giving the dependency room to recover. Use
opossumin production. - Fallbacks turn an outage into graceful degradation — cached values, safe defaults, or queued work.
- Bulkhead caps concurrency and gives each dependency its own connection pool, so one sick service can't starve the rest.
- Compose them per dependency: breaker around bulkhead around a timed call, with a fallback for the OPEN/rejected path.
Blind retries are the trap: they amplify load exactly when the system is weakest.
Często zadawane pytania
Czy lekcja „Wzorzec Circuit Breaker i izolacja Bulkhead” jest bezpłatna?
Tak — pełny tekst „Wzorzec Circuit Breaker i izolacja Bulkhead” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu Node.js Backend Development Bootcamp, przejdź na CoddyKit PRO. Kurs Node.js Backend Development Bootcamp zawiera 4 lekcji w sumie.
Co nauczysz się w „Wzorzec Circuit Breaker i izolacja Bulkhead”?
Powstrzymuj kaskadowe awarie, rozłączając obwody i izolując pule zasobów dla poszczególnych zależności. Ćwiczysz Node.js Backend Development Bootcamp z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.
Czy potrzebuję doświadczenia, aby zacząć Node.js Backend Development Bootcamp?
Nie wymagamy żadnego doświadczenia. Node.js Backend Development Bootcamp w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 2 z 4.
Ile czasu zajmuje lekcja „Wzorzec Circuit Breaker i izolacja Bulkhead”?
Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.
Czy mogę pisać i uruchamiać kod w tej lekcji Node.js Backend Development Bootcamp?
Tak. Każda lekcja Node.js Backend Development Bootcamp zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.
Wszystkie lekcje w tym kursie
- Limity czasu, ponowienia i wykładniczy backoff z jitterem
- Wzorzec Circuit Breaker i izolacja Bulkhead
- Kontrolowane zamykanie i opróżnianie żądań w toku
- Kontrole zdrowia, sondy gotowości i odrzucanie obciążenia