How Circuit Breakers Actually Work
Try the interactive lab for this articleTake the quiz (6 questions · ~6 min)Circuit breakers get explained with a cartoon: a service calls a dependency, the dependency starts failing, and the caller "opens the breaker" to protect itself.
The cartoon is useful in the same way a railway map is useful. It shows the shape. It hides the timing, the thresholds, the sample-size guards, the classification rules, the probe logic, and the awkward fact that a breaker is only one control in a larger failure-management system.
A real circuit breaker is a local admission-control policy for downstream uncertainty. It decides whether this process should start one more call right now, based on what recent calls to the same dependency looked like. That decision sounds small. In production it affects thread pools, socket pools, queue growth, tail latency, retry storms, fallback correctness, and how quickly a fleet can recover after an outage.
This article walks through the mechanics carefully. We will look at the state machine, which failures should count, how rolling windows trip, why half-open is where many designs go wrong, how breakers interact with timeouts and retries, where they should live, and which failure modes make them helpful in one incident and harmful in the next.
1. A circuit breaker is local admission control, not a fancy error handler
The most important point comes first: a circuit breaker makes its decision before the dependency call starts.
That distinguishes it from ordinary error handling.
Suppose a checkout service in Amsterdam calls a fraud-scoring service in Frankfurt before it authorises a card payment. If the fraud service is healthy, the caller sends the request, waits for the answer, and moves on. If the fraud service is overloaded and recent calls have started timing out, the caller may decide not to send the next request at all. It can fail fast, return a controlled fallback, or route the transaction for later manual review.
That is a different question from "what do I do after I got an exception?" The breaker is deciding whether to spend more time, more sockets, and more worker capacity on a dependency that currently looks unsafe.
Several controls get discussed together in resilience conversations, but they are not interchangeable:
| Control | Main question | Typical action |
|---|---|---|
| Timeout | How long am I willing to wait? | Abort the in-flight call after a deadline |
| Retry | Is another attempt worth trying? | Send the call again after a delay |
| Circuit breaker | Should I start this call at all? | Reject locally or use a fallback |
| Concurrency limit | How many calls may be in flight at once? | Queue or reject when slots are full |
| Rate limit | How many new calls may start per time unit? | Delay or reject new work |
If you blur those controls together, the system becomes hard to reason about.
A timeout bounds how long one bad call can occupy resources.
A retry assumes the failure might be transient.
A circuit breaker assumes the opposite for a short period: enough recent evidence says the dependency is unhealthy, so starting more calls is probably wasteful or actively harmful.
That last part matters. A breaker is not mainly there to protect the dependency. It is there to protect the caller and everything behind it:
- request threads
- async workers
- connection pools
- upstream latency budgets
- other features that share the same process
If your Dublin API waits 2 seconds on a sick recommendations service for every page view, the recommendations service is not the only thing in trouble. Your whole API becomes slower. Queues start to form. Retries begin to pile on. Memory holds more pending request state. The user-visible incident spreads outward.
A breaker tries to cut that propagation path.
That is why the name is useful. In an electrical circuit, a breaker interrupts current when the flow becomes unsafe. In software, it interrupts dependency traffic when continuing to send it is likely to worsen the fault domain.
2. The state machine is simple on paper and subtle in production
Most circuit breakers have three main states:
- Closed: requests are allowed through and outcomes are recorded.
- Open: requests are rejected locally for a cooling period.
- Half-open: a small number of probe requests are allowed through to test recovery.
The basic loop looks like this:
state = CLOSED
on request:
if state == OPEN and now < reopen_at:
reject_fast
if state == OPEN and now >= reopen_at:
state = HALF_OPEN
probes_remaining = 2
if state == HALF_OPEN and probes_remaining == 0:
reject_fast
result = call_dependency_with_timeout()
record(result)
if state == CLOSED and should_trip(metrics):
state = OPEN
reopen_at = now + open_wait + jitter
if state == HALF_OPEN:
if result.counts_as_failure:
state = OPEN
reopen_at = now + open_wait + jitter
else if enough_successful_probes_seen():
reset_metrics()
state = CLOSEDThis is conceptually tidy. Production behaviour depends on the details hiding inside record(result) and should_trip(metrics).
A few observations matter immediately.
Closed does not mean healthy forever
Closed means the breaker is allowing traffic. It does not mean the dependency is perfectly fine. A dependency can already be slow, flaky, or partially degraded while the breaker is still closed. The whole point of the closed state is to keep observing until enough evidence accumulates.
Open is local, not global truth
A breaker almost always lives inside one process, one pod, one sidecar, or one worker instance. If twelve instances of the same service are handling different traffic mixes, they may not open at the same time.
That surprises people the first time they see it on a dashboard.
Imagine six instances in Paris are handling mostly warm-cache users and six instances in Amsterdam are handling a spike of cold-cache traffic that calls an expensive search backend more often. The Amsterdam group may trip first because it sees the degradation first. That is not a bug. It is what local observation looks like.
Half-open is not a return to normal
Half-open is a guarded test state. Many bad implementations effectively treat it as "open is over, everybody may try again." That defeats the point.
If the dependency is only partly recovered, a flood of eager callers can push it straight back into failure before you learn anything. Half-open works only when the probe budget is deliberately small.
The breaker must still cooperate with timeouts
A breaker does not magically terminate a call that already started. If the caller sends a probe or an ordinary closed-state request, the timeout still decides how long that one call may consume resources. Breakers and timeouts always work together.
A timeline makes the interaction easier to see:
14:03:10 request A starts, dependency stalls
14:03:11 request B starts, dependency stalls
14:03:12 request C starts, dependency stalls
14:03:12 rolling window now exceeds trip threshold
14:03:12 breaker transitions CLOSED -> OPEN
14:03:12 request D arrives and is rejected locally
14:03:12 request E arrives and is rejected locally
14:03:12 requests A/B/C are still in flight until their own timeouts fireThe breaker stops new calls. It does not retroactively cancel every old one unless your stack explicitly wires cancellation through.
That distinction matters when you are reading incident graphs. Open-state rejection count may spike immediately, while worker saturation falls only after the already-started calls drain out or time out.
3. Choosing which failures count is harder than drawing the state machine
The hardest design question is usually not "do I need open and half-open?" It is "what should teach the breaker that the dependency is unhealthy?"
If you count the wrong events, you trip too early, too late, or for the wrong reasons.
A few signals are usually safe to count:
- TCP connect timeout
- TLS handshake timeout
- application timeout waiting for response bytes
- connection reset or refused
- HTTP 502, 503, or 504 from a downstream proxy or the dependency itself
- explicit local concurrency rejection from the downstream client pool, if that pool directly represents dependency saturation
Other signals are usually dangerous to count blindly:
- HTTP 400 because the caller sent a bad request
- HTTP 401 or 403 due to missing credentials
- HTTP 404 for a missing object
- caller-side cancellation because the user closed the page
- validation errors raised before any dependency call happened
A short classification table helps:
| Outcome | Usually count it? | Why |
|---|---|---|
| Connect timeout | Yes | The dependency path is not responding in time |
| Read timeout | Yes | The dependency is too slow for the caller's budget |
| HTTP 503 | Yes | The dependency or its gateway is explicitly overloaded or unavailable |
| HTTP 429 | It depends | Sometimes it means back off; sometimes it is a normal quota policy |
| HTTP 400 | No | The caller or client sent something invalid |
| HTTP 404 | Usually no | Missing data is not the same as service health failure |
| Caller cancelled request | Usually no | The dependency may have been fine |
The HTTP 429 row is where production nuance shows up.
If you are calling a partner API that uses 429 Too Many Requests to say "you are over my budget," treating that as a counted failure may be correct because more attempts are pointless right now. But if your own internal service uses 429 as a business throttle for one tenant while the service itself is healthy, tripping a broad breaker on that response would be wrong. You would turn one tenant's quota event into a fleet-wide dependency-health conclusion.
Slow calls deserve special attention.
Many modern breaker libraries do not only count hard failures. They also track slow-call rate. That is valuable because a dependency can still return 200 OK while behaving like an outage for the caller's latency budget.
If a search service in Warsaw usually answers in 40 ms but is now answering in 1.8 seconds, your frontend may be effectively broken even though the HTTP status is technically success. Counting slow calls lets the breaker trip before every request is a formal timeout.
The important part is choosing a slow threshold that matches the caller's real budget.
If the end-user page budget is 700 ms and the dependency normally gets 120 ms of that, a slow threshold of 2 seconds is operational theatre. It will teach the breaker far too late. If you set the slow threshold to 80 ms in a system whose normal p95 is 75 ms, the breaker will flap constantly.
This is why failure classification belongs close to the calling semantics.
A library breaker inside the application can often make richer decisions than a generic proxy breaker because it knows whether a 404 is normal, whether stale cached data is an acceptable fallback, and whether one endpoint is optional while another is mandatory. A mesh-level control sees only transport and HTTP status, which is useful but less precise.
4. Trip logic needs a rolling window, a threshold, and a minimum sample size
Once you know which outcomes count, you still need to decide when to open.
The simplest design is consecutive failures:
if last 5 calls all failed:
open breakerThat can work for some batch jobs. It is often too brittle for mixed traffic. A small burst of unlucky failures can trip it, while an alternating pattern like success, failure, success, failure can avoid tripping for longer than you want even though the dependency is clearly sick.
Most serious breakers use some form of rolling window plus thresholds.
Two common window shapes are:
- count-based window: the last N calls
- time-based window: the last T seconds
A count-based window is easier to reason about when traffic is steady. A time-based window adapts better when request rates vary wildly. Neither is universally superior. The key is matching the window to the fault you care about.
Suppose the breaker tracks the last 20 calls and opens when failure rate reaches 50 percent, but only after at least 10 calls have been seen.
The core formula is simple:
failure_rate = failed_calls / total_calls
slow_call_rate = slow_calls / total_callsTrip when one of these is true:
failure_rate >= 0.50 and total_calls >= 10
slow_call_rate >= 0.60 and total_calls >= 10That total_calls >= 10 guard is crucial.
Without a minimum sample size, the breaker can trip on tiny bursts with almost no statistical weight. Two failures out of two calls is a 100 percent failure rate. That is not necessarily enough evidence to stop all further traffic.
A concrete example:
Window A:
total calls = 8
failed = 4
failure rate = 50%
result = stay CLOSED because minimum volume not reached
Window B:
total calls = 12
failed = 7
failure rate = 58%
result = OPEN because threshold and minimum volume are both metThat one guard eliminates a large class of false positives during low traffic, cold starts, or one brief network wobble.
Many teams end up with a configuration like this:
resilience4j.circuitbreaker:
instances:
fraudService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallDurationThreshold: 750ms
slowCallRateThreshold: 60
waitDurationInOpenState: 20s
permittedNumberOfCallsInHalfOpenState: 2That configuration is not magic. It is a statement about a specific caller budget.
Read it in plain English:
- observe the last 20 calls
- do not trust the ratio until at least 10 calls exist
- open if at least half are failures
- also open if at least 60 percent are slower than 750 ms
- once open, wait 20 seconds before probing
- when probing, allow only 2 test calls
Every number encodes an operational tradeoff.
A short window reacts faster but can flap more.
A long window is stable but can keep stale outage history around for too long.
A low threshold trips early but risks false positives.
A high threshold delays protection.
A slow-call threshold near the caller's true latency budget catches brownouts. One far above the caller's budget mostly creates pretty metrics.
Do not pick these values because a blog post or a framework sample used them. Pick them because you know what normal latency looks like, what a dangerous tail looks like, and how much evidence you need before cutting traffic.
5. Open state is a cooling period for the caller, not a cure for the dependency
When the breaker opens, it is making a local bet: for some short period, the next call is more likely to harm than to help.
That bet buys three things.
First, it preserves caller resources.
If the dependency is taking 2 seconds to time out, every blocked call ties up a worker, a socket, and some memory. Failing fast gives that capacity back to the caller immediately.
Second, it stops load amplification.
A struggling dependency often fails more badly when a whole fleet keeps sending fresh requests plus retries. Open state cuts off the easy path for that amplification.
Third, it makes behaviour predictable.
A user would usually rather get one immediate, controlled failure than one 2.3 second spinner followed by a second retry and then a generic 500.
Open state still leaves a hard design question: what do you return instead?
The answer depends on the dependency.
Some fallbacks are semantically safe:
- serve slightly stale catalogue data
- omit a recommendations widget
- queue a webhook for later delivery
- degrade to a cached exchange rate if your business rules permit it
Some fallbacks are dangerous or dishonest:
- silently approve a payment because fraud scoring is down
- claim an inventory reservation succeeded when the reservation service was never called
- return "no seats available" when the booking service was simply unreachable
A breaker should fail fast. It should not fabricate a business truth.
The open duration also needs care.
If the wait is too short, the fleet hammers the dependency again before it has recovered.
If the wait is too long, recovery is discovered late and users suffer longer than needed.
This is a good place to add jitter. If every instance opens for exactly 20 seconds, they will also probe again at exactly the same second. That coordinated reopen can become its own load spike.
A better idea is:
reopen_at = now + wait_duration + random(0ms, 1500ms)The jitter does not need to be huge. It just needs to stop perfect alignment.
An operational log line from a useful breaker often looks like this:
2026-05-29T14:03:12Z service=checkout dependency=fraud-eu
breaker_transition=CLOSED->OPEN
window_calls=12 window_failures=7 window_slow=3
failure_rate=58.3% slow_call_rate=25.0%
open_for=20s jitter=742ms
fallback=manual-reviewThat one event tells you far more during an incident than a vague "short-circuit activated" message.
6. Half-open is where recovery is tested and where many implementations fail
Half-open is the recovery checkpoint.
The dependency has had a quiet period. The breaker now needs fresh evidence. The mistake is to treat half-open as a public announcement that traffic may resume.
The safe model is narrow:
- allow only a small probe budget
- keep everybody else rejected or queued
- evaluate probe outcomes strictly
- close only after enough success
- reopen immediately on counted failure
Suppose the breaker allows two probes.
A healthy sequence looks like this:
14:03:32.000 OPEN wait expires
14:03:32.001 breaker enters HALF_OPEN
14:03:32.005 probe 1 allowed
14:03:32.044 probe 1 succeeds in 39 ms
14:03:32.050 probe 2 allowed
14:03:32.097 probe 2 succeeds in 47 ms
14:03:32.098 breaker transitions HALF_OPEN -> CLOSEDA failure sequence should be equally decisive:
14:03:32.000 HALF_OPEN
14:03:32.005 probe 1 allowed
14:03:32.811 probe 1 times out
14:03:32.812 breaker transitions HALF_OPEN -> OPENNo normal traffic flood should slip in between those probes.
The reason is physical, not aesthetic. A dependency recovering from overload often has a shallow recovery curve. Its caches are cold. Its queues may still be draining. Its database pool may still be saturated. One or two successful probes do not prove it can absorb the whole fleet instantly.
That is why many systems pair half-open with limited concurrency even after closing. The breaker may close, but the caller still needs timeout discipline and connection-pool caps so a recovering dependency is not hit with a fresh cliff.
Multi-instance fleets make half-open trickier.
If two hundred pods all share the same 20 second wait and each allows two probes, the backend may suddenly see four hundred concurrent probes. That is no longer a probe. It is a burst.
Common mitigation patterns are:
- jitter the open wait
- allow only one probe per instance
- combine the breaker with a separate global concurrency cap
- probe through a smaller canary slice first
- keep autoscaling from multiplying cold callers during the recovery window
Another subtlety is metrics reset.
Once half-open succeeds and the breaker closes, do you clear the historical failure window or age it out gradually?
If you keep the entire old failure window, the next one or two slow calls can reopen immediately based on stale outage history. If you clear everything instantly, you may forget that the dependency is only barely healthy and let too much traffic in too quickly.
Different libraries choose differently. The right answer depends on how bursty the workload is and how fast the dependency normally stabilises after an incident.
7. Breakers only help when timeouts, retries, and bulkheads agree with them
A breaker by itself is not a resilience strategy. It is one control in a chain.
Timeouts set the cost of each mistake
If your timeout is longer than the caller can afford, the breaker learns too slowly. Every bad call occupies resources for too long before it gets recorded as a failure or a slow call.
For example, suppose an HTTP request has an 800 ms end-user budget. The caller spends 150 ms on auth, 100 ms on rendering, and 100 ms on database work. That leaves perhaps 200 to 250 ms for one optional dependency. A 2 second dependency timeout is irrational in that context.
The timeout and the slow-call threshold should reflect the real budget, not the hope that maybe the dependency will recover at the last moment.
Retries can multiply the pressure the breaker is trying to cut off
Retries are useful when failures are genuinely transient. They are dangerous when every layer retries blindly.
Consider a chain where a mobile app calls an edge API, which calls a pricing service, which calls a tax service. If each layer retries twice, one original request can turn into many downstream attempts under stress.
A simple multiplication sketch:
client -> edge retries 2 times
edge -> pricing retries 2 times
pricing -> tax retries 2 times
worst-case downstream tax attempts for one user action = 3 x 3 x 3 = 27This is how a small brownout becomes a large one.
A breaker helps by saying: enough recent attempts failed, so do not start attempt 28.
The retry policy has to respect that decision. Once the breaker is open, retry loops should back off or stop, not spin inside the same request path.
Idempotency matters here as well. If the operation is a non-idempotent write, the caller must know whether retrying after a timeout is safe. Breakers do not solve that ambiguity. They only stop new attempts when recent evidence says the path is unhealthy.
Bulkheads and concurrency limits protect a different bottleneck
A breaker controls whether work starts. A bulkhead or concurrency limit controls how much work may be in flight at once.
Little's law gives the intuition:
in_flight ≈ arrival_rate × service_timeIf the service time of a dependency suddenly jumps from 40 ms to 2 seconds, the same arrival rate creates far more in-flight work. A breaker helps after it learns from those slow calls. A concurrency limit protects you immediately by refusing to let in-flight work exceed a safe bound.
That is why the common pairing is:
- timeout to bound one call
- concurrency limit to bound simultaneous calls
- breaker to stop new calls after a recent unhealthy pattern
- retry only when semantics justify it
If any of those controls contradict the others, the incident becomes noisy.
A common bad pattern is a small client connection pool plus aggressive retries plus a breaker whose slow-call threshold is longer than the connection-acquire timeout. In that case the pool can saturate before the breaker ever decides the dependency is sick.
8. Placement changes what the breaker can see and how the fleet behaves
Where you put the breaker changes the shape of the control.
In-process application breaker
This is the classic library pattern. Java teams often use Resilience4j. Older Netflix stacks used Hystrix. Other languages have equivalent libraries.
Advantages:
- rich knowledge of business semantics
- can classify errors precisely
- can choose endpoint-specific fallbacks
- can emit domain-aware metrics
Disadvantages:
- every language stack must implement it correctly
- state is per process unless you add coordination
- configuration drift across services is easy
Proxy or sidecar breaker
A sidecar or service-mesh proxy can enforce resilience policy without requiring each team to write the same code in Go, Java, Node, and Python.
Advantages:
- language neutral
- central policy rollout
- uniform telemetry
Disadvantages:
- sees transport and HTTP, not full business meaning
- harder to know whether a
404is normal - fallbacks are usually generic rather than domain specific
Async workers need breakers too
Breakers are not only for synchronous request-response traffic.
A webhook delivery worker, an email sender, a fraud-file uploader, or a batch reconciler can all benefit from local admission control. The symptom is the same: repeated calls to the same downstream keep failing or going slow, and continuing to start new ones harms the worker fleet.
Service mesh naming is confusing on purpose if you are not careful
One practical trap is that some proxy stacks use the term circuit breaker for limits that are not Hystrix-style open or half-open state machines.
Envoy is a good example. Its circuit_breakers section is mostly about resource caps such as max connections, pending requests, active requests, and retries. It is closer to a bulkhead than to a failure-rate state machine.
A representative snippet looks like this:
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 2000
max_pending_requests: 200
max_requests: 500
max_retries: 50
outlier_detection:
consecutive_5xx: 5
interval: 10s
base_ejection_time: 30sThose two blocks are useful. They do different jobs.
The first block caps resource use for an upstream pool.
The second block ejects individual unhealthy hosts from a pool.
Neither one, by itself, gives you a full application-level breaker with endpoint-specific failure classification, half-open probe semantics, and domain-aware fallback behaviour.
If your team says "we already have circuit breakers in Envoy," the next question should be: which kind?
That naming confusion causes a lot of architecture drift.
9. The nastiest production failures are coordination failures, not missing state diagrams
Most breaker incidents do not happen because someone forgot that open leads to half-open.
They happen because the control is technically present but operationally misaligned.
Counting caller bugs as dependency failures
If malformed requests generate 400 responses and you count them as dependency health failures, the caller can trip its own breaker through a bad deploy even though the dependency is perfectly healthy.
No minimum volume on low-traffic paths
A rarely used admin endpoint with two bad calls should not necessarily flip into open state for the next five minutes. Minimum sample size exists for a reason.
Coordinated reopen after the exact same wait period
A whole fleet sleeping for exactly 30 seconds and then probing together is a replay of the original overload in smaller form. Add jitter.
Breaker state lost on every deploy
If you redeploy the caller during the downstream outage, every new pod starts with a cold breaker state. The fleet forgets what it just learned and rediscovers the outage the hard way. This is one reason incidents sometimes get worse during a rollout.
Falling back to a lie
Returning stale catalogue data can be fine. Returning a fake success for a money-moving operation is not. Fast failure is often safer than a dishonest fallback.
Local breaker state interpreted as global truth
One instance opening does not prove the whole dependency is universally down. It proves that this caller instance recently observed bad enough behaviour on this path. Operators who forget that sometimes chase phantom inconsistencies between pods that are really just seeing different traffic mixes.
Breaker flapping on long-tail noise
If the thresholds are too aggressive, the service alternates between closed and open under ordinary variance. Users experience an erratic system, and operators stop trusting the metrics. That is worse than no breaker because the control now adds noise without adding safety.
A good breaker is boring most of the time. It trips rarely, clearly, and for reasons you can explain after the fact.
10. Measure the breaker like part of the control plane or it will surprise you in incidents
Breakers are control infrastructure. They need the same observability discipline as queues, schedulers, or load balancers.
At minimum, you want to measure:
- current breaker state per dependency and caller
- transition count by direction, such as
closed_to_open - rejected request count while open
- half-open probe attempts and outcomes
- failure rate and slow-call rate seen by the breaker
- fallback usage, if a fallback exists
- dependency latency and error rate on the same dashboard
A useful metric set might look like this:
breaker_state{service="checkout",dependency="fraud-eu"} = 1 # open
breaker_transitions_total{from="closed",to="open"} += 1
breaker_rejected_calls_total{dependency="fraud-eu"} += 14
breaker_half_open_probes_total{result="success"} += 2
breaker_failure_rate{dependency="fraud-eu"} = 0.583
breaker_slow_call_rate{dependency="fraud-eu"} = 0.250The dashboard should also show what happened around the same time:
- dependency p95 and p99 latency
- caller worker-pool occupancy
- caller connection-pool saturation
- retry volume
- fallback volume
- upstream error budget burn
That correlation tells you whether the breaker helped.
If the dependency latency exploded, the breaker opened, caller worker saturation fell, and fallback volume rose in a controlled way, the breaker did its job.
If the breaker opened but retry traffic kept climbing, or caller threads stayed saturated because the timeout was too long, the breaker was only a partial control.
Alerting should track transitions and impact together.
An alert that only says "breaker open" is often too noisy. Some breakers open for a few seconds during harmless downstream maintenance and the fallback path absorbs the event cleanly. A better alert joins state with consequence:
- breaker has been open for more than N seconds
- rejected-call volume is above the normal baseline
- fallback path is unavailable or expensive
- user-facing latency or error budget is also worsening
That combination tells you the control is no longer just trimming the edge of a fault. It is now materially shaping user experience or business flow. The same principle applies to dashboards. Put breaker state beside dependency latency, caller saturation, and fallback usage so operators can see whether the control is containing the incident or merely making the symptoms look different.
Testing matters too.
Do not only unit-test the state machine. Run targeted failure drills:
- inject 2 second latency into the dependency and confirm the breaker opens after the expected volume
- inject 400 responses and confirm the breaker stays closed if those errors are meant to be ignored
- inject 503 responses and confirm the open wait and half-open probe budget behave as configured
- recover the dependency gradually and confirm the fleet does not stampede it during half-open
This is also where log quality matters. During an incident, operators need to answer concrete questions:
- Which dependency is tripping the breaker?
- Is it one endpoint or all endpoints?
- Is the trigger failure rate, slow-call rate, or pool saturation?
- Are fallbacks being used, and are they safe?
- Are all pods opening, or only a subset?
A log line that includes the dependency name, route, threshold reason, window counts, and next reopen time is far more valuable than a generic exception string.
One more practical check helps: compare breaker transitions against deploys, autoscaling events, and dependency maintenance windows. If every open transition starts right after a rollout, the control may be exposing your own client bug rather than a downstream outage. Breakers are good incident tools. They are also good bug detectors when their evidence is precise.
Circuit breakers are useful because they are opinionated about when not to spend more time
A circuit breaker is not a magic shield around a service. It is a local control that says: recent evidence suggests this dependency path is unhealthy enough that starting another call is more likely to waste resources than to produce a useful answer.
That control works only when the rest of the system agrees with it.
- Timeouts must reflect the caller's real budget.
- Retries must not multiply load after the breaker has already learned the path is sick.
- Concurrency limits must still protect in-flight occupancy.
- Failure classification must distinguish dependency health from caller mistakes.
- Half-open must stay narrow enough to test recovery without recreating the outage.
When those pieces line up, a breaker turns a spreading failure into a contained one. When they do not, the breaker becomes one more moving part that makes the incident harder to read.
That is the real mechanism. The value is not in the word "open." The value is in deciding, with honest evidence and tight timing, when the next call is no longer worth starting.