Essential 02

Circuit Breakers

A dependency that is down costs you one failed request. A dependency that is slow costs you the whole service: every worker thread parked on a socket that will answer eventually, and endpoints that never touch the faulty dependency returning nothing at all. This page is about the control that stops that, the control that has to come first, and the arithmetic that makes both of them concrete.

Level Intermediate Stack Java 17 · Spring Boot · Resilience4j Status Prescriptive, not yet built
Read this as a proposal, not as a report

This pattern was absent from the estate this hub was written against. There was no circuit breaker, no bulkhead and no explicit timeout policy on any service-to-service client, and none was introduced afterwards. Everything below is what to build and what it will cost: a design under consideration, with its trade-offs stated. No part of this page is a claim of work done.

The claim under test

"The call either works or it throws." That sentence is the reason most service-to-service clients ship with no resilience configuration at all: if a failure surfaces as an exception, the existing catch block already handles it. The claim is not wrong so much as incomplete, and the missing case is the one that takes the service down.

One of the big differences between in-memory calls and remote calls is that remote calls can fail, or hang without a response until some timeout limit is reached.

Hang without a response is the third outcome, and it is not a rarer version of failure. It is a categorically different one. A thrown exception returns your thread. A hang keeps it. And a thread is not an abstraction: it is a finite, pre-allocated, shared resource that every endpoint in the process draws from.

The arithmetic of saturation

Put numbers on it, because the numbers are what make the failure feel inevitable rather than unlucky. A service with a 200-thread worker pool, 50 requests per second inbound, and one dependency that has started answering in 30 seconds instead of 30 milliseconds:

textthread-pool saturation, worked through
dependency      telemetry-service, alive and answering in 30 s
inbound         50 req/s, and every request touches it
worker pool     200 threads

each in-flight request holds exactly one thread
  threads consumed   50 per second
  pool drained in    200 / 50 = 4 seconds

Little's Law: concurrency = arrival rate × latency
  required           50 req/s × 30 s = 1500 threads
  available          200 threads
  shortfall          7.5×

the same service, one line of configuration different:
  timeout            1 s
  required           50 req/s × 1 s = 50 threads
  available          200 threads       → steady state, with headroom

Four seconds. That is the entire budget between "a dependency got slow" and "this service answers nothing". Nothing crashed, no exception was thrown, no error appeared in a log, and the health check, if it runs on the same pool, is now also queued behind 200 blocked workers, so the orchestrator will shortly restart a process whose only fault was politeness.

The last three lines of that block are the whole argument of this page in numeric form, and we will come back to them. A timeout does not make the dependency healthy. It makes the concurrency requirement bounded, which is the only property that keeps the pool solvent.

The cascade, mechanically

Saturation does not stay where it started. The service that ran out of threads is itself a dependency of something, and its callers are blocked on it for the same reason it is blocked on the thing beneath it. The fault travels upstream, against the direction of the call, one thread pool at a time.

What's worse if you have many callers on a unresponsive supplier, then you can run out of critical resources leading to cascading failures across multiple systems.

edge-service 50 req/s inbound mission-service calls telemetry on every request telemetry-service alive, answering in 30 s worker pool, 200 threads worker pool, 200 threads worker pool, 200 threads 200 / 200 blocked t = 9 s, total outage 200 / 200 blocked t = 4 s 120 / 200 in use t = 0 s, the only real fault saturation travels upstream, against the direction of the call what the operator sees none of these endpoints call telemetry /areas · /crew · /alerts all timing out, no error in any log the arithmetic 200 threads / 50 req/s = 4 s 50 req/s × 30 s = 1500 threads Little's Law: the pool is 7.5× short
The only broken thing is on the right, and it is not broken. It is slow. Everything red is collateral. Note the bottom-left box: the endpoints that fail have no relationship to telemetry at all; they simply share a thread pool with something that does. That is why "the blast radius of a dependency" is not the set of features that use it.

A cascading failure is a failure that grows over time as a result of positive feedback.

Thread starvation can directly cause errors or lead to health check failures.

Michael Nygard named the remedy, and Fowler is explicit about the attribution, worth getting right, because the pattern is routinely cited as Fowler's:

In his excellent book Release It, Michael Nygard popularized the Circuit Breaker pattern to prevent this kind of catastrophic cascade.

The basic idea behind the circuit breaker is very simple. You wrap a protected function call in a circuit breaker object, which monitors for failures. Once the failures reach a certain threshold, the circuit breaker trips, and all further calls to the circuit breaker return with an error, without the protected call being made at all.

Martin Fowler, on Michael Nygard's Release It! martinfowler.com/bliki/CircuitBreaker.html

Chris Richardson's framing of the same idea states the problem as a question, which is a useful way to test whether you have the pattern or merely the annotation: "How to prevent a network or service failure from cascading to other services?" His answer, "A service client should invoke a remote service via a proxy that functions in a similar fashion to an electrical circuit breaker", puts the control on the caller, not the callee. The breaker protects you from your dependency. It does nothing for your dependency, and nothing for a dependency that has no breaker in front of it.

A timeout is the first line of defence

This is the part most write-ups skip, and skipping it is how teams ship a breaker that protects nothing. A circuit breaker is a statistical control. It reacts to outcomes. A call that never returns produces no outcome. It is not a success, it is not a failure, it is not even a slow call: the breaker computes the slow-call rate from completed calls, and a hung call has not completed.

A breaker around a call with no timeout is decoration

Wrap an unbounded call in a breaker and here is what happens when the dependency hangs: every request still blocks for the transport default, the pool still drains in four seconds, and the breaker still reads CLOSED the whole time, because from its point of view nothing has failed yet. The first failure it can record arrives when the first socket finally gives up, by which time the outage is minutes old.

A JDK HttpURLConnection with no timeout set blocks until the operating system abandons the socket, which is measured in minutes. Java's HttpClient applies no request timeout unless you call timeout(...). Defaults across HTTP libraries differ, and the only safe assumption is that the default is wrong for your call. Set it explicitly, on every client, and treat an unset timeout as a review blocker.

You need two timeouts and they are not interchangeable. The connect timeout bounds how long you wait for a TCP handshake. It should be short, because a healthy peer answers in milliseconds and an unreachable one will not answer at all. The read or response timeout bounds how long you wait for the answer once connected, and it is the one that saves the thread pool. Above both, an application-level deadline (Resilience4j's TimeLimiter) bounds the whole operation including retries and redirects.

With a timeout in place, the breaker finally has something to work with: every call now terminates within a known bound, so the window fills with real outcomes and the failure rate becomes meaningful. That is the correct mental order: the timeout makes the failure observable; the breaker decides what to do about the pattern of failures. Neither substitutes for the other. A timeout alone still lets you spend 3 seconds per request discovering the same thing 50 times a second; a breaker alone never learns.

The three states

Resilience4j implements the pattern as a state machine, and the vocabulary is worth using precisely because each state has a different contract with the caller:

The CircuitBreaker is implemented via a finite state machine with three normal states: CLOSED, OPEN and HALF_OPEN and three special states METRICS_ONLY, DISABLED and FORCED_OPEN.

Resilience4j documentation resilience4j.readme.io/docs/circuitbreaker
failure rate ≥ 50% OR slow-call rate ≥ 50% over ≥ 20 recorded calls wait duration elapsed and the next call becomes the trial CLOSED the call is made outcome joins the window OPEN no call is made at all CallNotPermittedException HALF_OPEN 3 trial calls permitted all others still rejected a trial call fails or runs slow and it goes straight back to open all permitted trial calls succeed and the breaker closes and the window resets
Every transition is driven by an aggregate, never by a single call, except one. The return from HALF_OPEN to OPEN happens on the first failed trial, because the purpose of the trial is to answer one question cheaply, and one bad answer settles it. Note also that OPEN does not become HALF_OPEN on a timer by default: the wait duration must elapse and a call must arrive.

The transitions in the documentation's own words. Out of CLOSED: "When the failure rate is equal or greater than the threshold the CircuitBreaker transitions to open." And back:

After a wait time duration has elapsed, the CircuitBreaker state changes from OPEN to HALF_OPEN and permits a configurable number of calls to see if the backend is still unavailable or has become available again.

Resilience4j documentation resilience4j.readme.io/docs/circuitbreaker

Fowler describes the same third state in one sentence, "There is now a third state present - half open - meaning the circuit is ready to make a real call as trial to see if the problem is fixed", and its resolution in another: "Asked to call in the half-open state results in a trial call, which will either reset the breaker if successful or restart the timeout if not."

Why the trial must be rationed

permittedNumberOfCallsInHalfOpenState is the least understood setting on the breaker and the one that decides whether recovery works. Suppose it were unlimited. The breaker has been OPEN for ten seconds while a dependency restarted, cold, with an empty cache and an unwarmed connection pool. The wait elapses, the breaker half-opens, and the entire offered load, ten seconds of it from every replica of every caller at once, arrives in the same instant. The dependency dies again, the breaker reopens, and you have built an oscillator that guarantees the outage never ends.

A half-open breaker asks a question, so it should send a question's worth of traffic. Three concurrent calls tell you what three thousand would, and cost nothing if the answer is bad.

Tuning: the knobs and where to start

Two decisions dominate everything else: what the window measures and how much evidence is enough. Resilience4j offers two window shapes, "The count-based sliding window aggregrates the outcome of the last N calls" and "The time-based sliding window aggregrates the outcome of the calls of the last N seconds", and the difference matters most on quiet endpoints. A count-based window of 100 on an endpoint that receives four calls an hour is a window a day wide: a failure from yesterday morning still votes on whether to trip this afternoon.

The evidence threshold is minimumNumberOfCalls, and it exists for exactly one reason: a breaker must not trip on the first failure of a low-traffic endpoint. Its job, per the documentation, is to configure "the minimum number of calls which are required (per sliding window period) before the CircuitBreaker can calculate the error rate or slow call rate". Without it, one call, one failure, a 100% failure rate, and a breaker that opens on a single transient blip and denies service for the whole wait duration. With it set to 20, a single failure is 5% and the breaker correctly does nothing.

Slow calls are a separate trigger

A dependency answering every request successfully in four seconds has a zero percent error rate. An error-rate breaker will never open for it, and it destroys you exactly as described at the top of this page. This is why the slow-call rate is a distinct trigger with its own threshold rather than a refinement of the error rate: "The CircuitBreaker considers a call as slow when the call duration is greater than slowCallDurationThreshold", and when the percentage of slow calls crosses its own threshold the breaker transitions to open on that basis alone.

Set slowCallDurationThreshold below your TimeLimiter deadline. If they are equal, every slow call becomes a timeout before the breaker can count it as slow, and the slow-call trigger never fires. A threshold at roughly two-thirds of the deadline gives the breaker a band in which to notice degradation before it becomes failure.

KnobWhat it decidesStarting pointWhy that number
slidingWindowType Whether the window is the last N calls or the last N seconds. TIME_BASED Bounded in wall clock, so a quiet endpoint cannot carry stale evidence for hours. Switch to COUNT_BASED only for a genuinely high, steady call rate.
slidingWindowSize Window width: 60 seconds, or 60 calls. 60 Long enough to ride out a single bad GC pause, short enough that recovery is visible within a minute.
minimumNumberOfCalls How much evidence before any rate is computed at all. 20 The default of 100 never trips on a quiet endpoint; 1 trips on noise. At 20 a single failure is 5%, well under any threshold.
failureRateThreshold Percentage of recorded failures that opens the breaker. 50 Below 50 you trip on ordinary turbulence; above it you keep calling a dependency that fails more often than it succeeds.
slowCallDurationThreshold Above this duration a successful call is recorded as slow. 2s Roughly two-thirds of a 3s deadline, so degradation is detectable before it turns into timeouts.
slowCallRateThreshold Percentage of slow calls that opens the breaker, independent of errors. 50 The trigger that catches the failure this page is about: a dependency with a 0% error rate that is exhausting your pool.
waitDurationInOpenState How long the breaker stays OPEN before a trial is allowed. 10s Long enough for a restart or a queue to drain; short enough that a recovered dependency is not shut out for a minute. The default of 60s is a long time to be down after the fault has cleared.
permittedNumberOfCallsInHalfOpenState Concurrent trial calls allowed while HALF_OPEN. 3 A probe, not a load test. Enough to distinguish one lucky response from a recovery; too few to re-kill a cold dependency.
ignoreExceptions Outcomes that are neither success nor failure for breaker purposes. 4xx domain errors A 404 is an answer. Counting your own bad requests as dependency failures trips the breaker on a client bug and hides it.
These are starting points, not universal truths

Every number above is a hypothesis about traffic you have not measured yet. The correct values depend on your call rate, your latency distribution and how much staleness your product can tolerate, none of which a table can know. Ship these, put the breaker on a dashboard, and re-tune from what you see. A configuration that has never been revised after its first real incident is a guess that got lucky.

Fallbacks, and the fallback that is a second outage

An open breaker fails in microseconds instead of seconds. That protects your thread pool, and on its own it changes a slow error into a fast one, which is a real win for stability and no win at all for the user. What the user experiences is decided by the fallback. There are three honest shapes:

  1. Stale data, labelled as stale

    The last good answer, served from an in-process cache that the success path populates, with a marker the client can render: "telemetry as of 14:02". Best available outcome when the data is a snapshot of something that changes gradually. Requires the product decision to be made in advance: someone must agree that stale is better than absent, and for some data it is not.

  2. Degraded response

    Return the screen without the part that failed: the mission view with an empty telemetry panel and an explicit "unavailable" state, rather than a 500 for the whole page. This is usually the highest-value option and the one that requires the most work, because the response contract has to admit partial answers from the beginning.

  3. Fail fast, and say so

    A 503 with Retry-After, returned immediately. Unglamorous, and correct when there is no meaningful degraded answer: a write path, an authorisation decision, anything where a wrong answer is worse than no answer. Failing fast is still a fallback: it converts an indefinite hang into a bounded, honest signal that a caller can act on.

A fallback that is safe

Reads only from memory the process already holds. No socket, no lock held across I/O, no disk. Its worst case is measured in microseconds and does not depend on anything outside the JVM. It is exercised by a test that asserts the degraded shape, so it is known to work before the incident rather than during it.

A fallback that is a second cascade

Calls a secondary endpoint, a remote cache, a "backup" region. Each of those is a network dependency reached at the precise moment your thread budget is already gone, and it is very likely unhealthy for the same reason as the primary, since shared infrastructure is what made the primary fail.

You have not added resilience. You have added a second unbounded call on the failure path, which is the worst place in the system to put one.

The test is mechanical: if the fallback can block, it is not a fallback. A remote cache is a network call. A database read is a network call. A synchronous log shipment is a network call. If you genuinely need a remote secondary, it needs its own breaker, its own timeout and its own bulkhead, at which point you are running two protected dependencies, and the fallback of the fallback still has to be local.

The two patterns that travel with it

A breaker alone is a partial control. It acts per dependency, after enough evidence has accumulated, and it says nothing about how much of your process a single dependency may own in the meantime. Two companions close those gaps, and one of them is probably already in your codebase making things worse.

Bulkheads: isolating the pools

The bulkhead takes its name from a ship's compartments: a hull breach floods one compartment instead of the vessel. Applied to a service, it caps how many threads any one dependency may hold, so a dependency that goes slow can consume its quota and no more. Where a breaker responds to a pattern of failures, a bulkhead enforces a hard limit from the first call, including during the window before the breaker has enough evidence to act, which is exactly when saturation happens.

Resilience4j provides two implementations of a bulkhead pattern that can be used to limit the number of concurrent execution:

a SemaphoreBulkhead which uses Semaphores

a FixedThreadPoolBulkhead which uses a bounded queue and a fixed thread pool.

Resilience4j documentation resilience4j.readme.io/docs/bulkhead

The semaphore variant is the one to reach for first: maxConcurrentCalls permits handed out on the calling thread, with maxWaitDuration set to zero so a saturated bulkhead rejects immediately rather than parking a thread waiting for a permit, because queueing for a permit is a smaller version of the problem you are solving. Size the quotas so that the sum across all dependencies is comfortably under the worker pool. If five dependencies each get 20 permits out of a 200-thread pool, 100 threads are always available for work that calls nothing, and the bottom-left box in the diagram above never turns red.

Retries: the amplifier you already have

Retries are usually added long before breakers, by someone reasonable, for a good reason, and they are the fastest way to convert a degraded dependency into a dead one. The arithmetic is brutally simple: a service at its limit receiving 10,000 requests per second starts failing 1% of them, each failure is retried, and the SRE book traces where that goes: "Those 100 failed QPS are retried in MakeRequest every 1,000 ms, and probably succeed. But the retries are themselves adding to the requests sent to the backend, which now receives 10,200 QPS." The feedback loop is positive, which is the definition of a cascading failure quoted earlier.

Worse, retries synchronise. Every caller that failed at the same instant, and they all failed at the same instant because they all called the same sick dependency, waits the same interval and returns together. Backoff spreads the waves out but does not desynchronise them, which is the observation behind the standard fix:

The problem also stands out: there are still clusters of calls.

The solution isn't to remove backoff. It's to add jitter.

The SRE book gives the same instruction as a rule, "Always use randomized exponential backoff when scheduling retries", with the reason attached: "If retries aren't randomly distributed over the retry window, a small perturbation can cause retry ripples to schedule at the same time." It also names the ceiling most implementations lack: "Consider having a server-wide retry budget. For example, only allow 60 retries per minute in a process, and if the retry budget is exceeded, don't retry; just fail the request."

Never blind-retry a non-idempotent write

A timeout does not tell you the request failed. It tells you you did not hear back. The write may have committed, the response may have been lost on the way home, and a retry then performs it twice. That is a duplicate order, a double charge, a second dispatch.

Retry a write only when it is genuinely idempotent, or when the caller supplies an idempotency key the receiver deduplicates on. Configure this explicitly: put the write's exception type in ignoreExceptions rather than relying on nobody ever adding @Retry to that method.

The problem this would solve

The starting condition: structurally verified, and still unaddressed

In an eleven-service estate these facts were established by reading the source tree, not inferred from behaviour:

  • Service-to-service calls used declarative HTTP clients across 16 call edges, each carrying a hardcoded URL override that defeats service discovery, so every edge was pinned to one address with no health awareness and no ability to re-route.
  • No resilience configuration was found anywhere. No circuit-breaker library on the classpath, no bulkhead, and no explicit timeout policy on any of those clients. Every one of the 16 edges was an unbounded call.
  • The busiest service made synchronous calls to five other services while handling a single request, on the request thread, in sequence.
  • One service also instantiated a raw HTTP client directly for calls to a central logging service: an unpooled, unmanaged dependency sitting on the request path, where a slow log sink becomes a slow API.

No breaker, bulkhead or timeout policy was subsequently introduced. The third bullet is the one that compounds: five synchronous dependencies do not add their failure rates, they multiply their availabilities.

…/client/*Client.java 16 call edges · 0 resilience config raw HTTP client on the request path

That last point deserves the arithmetic, because it is the number that turns an architecture diagram into a service-level objective. Assume all five dependencies are required to answer the request and that their failures are independent, the friendliest possible assumptions, and both of them optimistic:

textthe availability ceiling of a synchronous fan-out
the busiest service calls five others synchronously while handling a request

each dependency at   99.9 %   ( 43.2 min of downtime per month )

all five required, failures independent:
  0.999 ^ 5  =  0.999 × 0.999 × 0.999 × 0.999 × 0.999
             =  0.995010
             =  99.501 %

so before this service's own bugs, its own GC and its own deploys:
  ceiling              99.501 %
  downtime             3 h 36 min per month   ( 43.7 h per year )
  cost of the fan-out  5× the downtime of any single dependency

a breaker does not raise this ceiling.
it converts "hung for 30 s" into "degraded in 3 ms".
the fallback is the only thing that raises effective availability.

Three consequences follow, and only the third is the breaker's job. First, the ceiling is structural: no amount of care inside this service buys back the 0.499% its dependencies spend. Second, the honest fixes are architectural: make a dependency asynchronous, make it optional, or cache its answer. Each removes a factor from the product. Third, for the calls that must stay synchronous and required, the breaker plus a fallback changes what "unavailable" means: not a hung request and a drained pool, but a fast, partial, labelled answer. The multiplication still happens. What it multiplies is no longer an outage.

Doing it properly

Three tabs, in the order the change happens. The first is what exists; the second is the protection and the configuration that drives it; the third is the fallback, which is where most implementations quietly reintroduce the problem.

javamission-service/…/MissionAssembler.java
package estate.mission.service;

import java.util.List;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * One of sixteen call edges shaped exactly like this. The url attribute pins
 * the client to a single address, so the name never reaches a discovery
 * client. There is no breaker and no bulkhead.
 *
 * Be precise about the timeout, because the sloppy version of this claim is
 * wrong: Spring Cloud OpenFeign DOES register a default Request.Options,
 * currently 10s connect and 60s read. So the call is bounded - but bounded at
 * 60 seconds, which for a user-facing request is indistinguishable from
 * hanging, and is far longer than any pool can absorb under load. The defect
 * is not "unbounded", it is "bounded by an accident rather than a decision".
 */
@FeignClient(name = "telemetry-service", url = "${telemetry.base-url}")
interface TelemetryClient {

    @GetMapping("/telemetry/{areaId}")
    List<TelemetrySample> latestFor(@PathVariable String areaId);
}

/**
 * The busiest service in the estate. Five synchronous dependencies on the
 * request path, resolved one after another on the worker thread that is
 * serving the user. Its availability is the product of theirs, and its
 * thread pool is shared with every endpoint it exposes, including the ones
 * that call none of these clients.
 */
@Service
class MissionAssembler {

    private final AreaClient areas;
    private final CrewClient crew;
    private final TelemetryClient telemetry;
    private final GeofenceClient geofences;
    private final AlertClient alerts;

    MissionAssembler(AreaClient areas, CrewClient crew, TelemetryClient telemetry,
                     GeofenceClient geofences, AlertClient alerts) {
        this.areas = areas;
        this.crew = crew;
        this.telemetry = telemetry;
        this.geofences = geofences;
        this.alerts = alerts;
    }

    /**
     * Five blocking calls on one thread. If any single dependency starts
     * answering in 30 s instead of 30 ms, this thread is held for 30 s, and
     * so is every other thread that reaches this method. At 50 req/s the
     * 200-thread pool is gone four seconds later.
     */
    MissionView assemble(String missionId, String areaId) {
        var area       = areas.byId(areaId);
        var onDuty     = crew.onDutyIn(areaId);
        var samples    = telemetry.latestFor(areaId);
        var fences     = geofences.forArea(areaId);
        var openAlerts = alerts.openFor(missionId);

        return MissionView.of(missionId, area, onDuty, samples, fences, openAlerts);
    }
}
A breaker you cannot see trip is a breaker you will not trust

Fowler puts the operational requirement in one line: "Any change in breaker state should be logged and breakers should reveal details of their state for deeper monitoring." This is not a nice-to-have. An invisible breaker produces the worst failure mode available: an incident where nobody can tell whether the degraded responses are the breaker working correctly or a bug, and the first instinct is to disable it.

Resilience4j publishes resilience4j.circuitbreaker.state, resilience4j.circuitbreaker.calls, resilience4j.circuitbreaker.failure.rate, resilience4j.circuitbreaker.slow.call.rate and resilience4j.circuitbreaker.not.permitted.calls through Micrometer, tagged by name, state and kind. Put state on a dashboard, alert on any transition to OPEN, and alert separately on a fallback rate that rises without a breaker opening. That combination means something is failing below the threshold you set, which is a tuning signal you will not get any other way.

Before you ship it

Adding the annotation satisfies none of this. Each line below is a thing to verify, and the last one is the only one that produces evidence rather than intent.

Canonical sources