How cascading failures happen and how a circuit breaker structurally prevents them
When an external REST API service responds slowly or fails completely without the client reacting, requests pile up, threads or workers block, and the outage spreads into your own application. The circuit breaker pattern breaks this chain by stopping calls to the failing service entirely after repeated failures and returning a fallback immediately instead.
Table of Contents
- 1. How a single slow service can bring down the whole application
- 2. The three states: closed, open, half-open
- 3. Configuring thresholds correctly instead of guessing arbitrarily
- 4. Fallback strategies for the open state
- 5. Combining a circuit breaker with Symfony's HttpClient
- 6. Why circuit breaker and retry complement rather than replace each other
- 7. Making circuit breaker state changes observable
- 8. Deliberately testing circuit breaker behavior
- 9. Circuit breaker at a glance
- 10. Summary
- 11. FAQ
1. How a single slow service can bring down the whole application
A classic failure mode in distributed systems occurs when a backend service A synchronously calls an external REST API service B, and B suddenly starts responding very slowly instead of cleanly returning an error. Without explicit timeout handling, every request to A that calls B waits correspondingly long, causing worker processes or PHP-FPM threads at A to pile up until eventually all available workers are blocked waiting on the slow B.
The result is that A becomes unreachable for ALL requests, not just the ones that actually need B, simply because there are no free workers left. A single slow dependency service can take down an entire application this way, even though the actual fault lies far away in a foreign system. This is exactly the pattern the circuit breaker prevents structurally.
2. The three states: closed, open, half-open
A circuit breaker works conceptually like an electrical fuse switch with three states. In the closed state, all requests pass through normally while failures are continuously counted. Once the failure rate within a time window exceeds a configured threshold, the breaker switches to the open state.
In the open state, requests to the failing service are not sent at all, but immediately answered with an error or fallback, which relieves the failing service and protects the calling application from further blocking waits. After a configured timeout period, the breaker switches to half-open, where a limited number of test requests are let through to check whether the service has recovered. If these test requests succeed, the breaker switches back to closed, otherwise back to open.
<?php
declare(strict_types=1);
enum CircuitState: string
{
case Closed = 'closed';
case Open = 'open';
case HalfOpen = 'half_open';
}
final class CircuitBreaker
{
public function __construct(
private readonly \Redis $redis,
private readonly string $serviceKey,
private readonly int $failureThreshold = 5,
private readonly int $openTimeoutSeconds = 30,
) {
}
public function getState(): CircuitState
{
$state = $this->redis->get("cb:{$this->serviceKey}:state");
return $state ? CircuitState::from($state) : CircuitState::Closed;
}
public function recordSuccess(): void
{
$this->redis->del("cb:{$this->serviceKey}:failures");
$this->redis->set("cb:{$this->serviceKey}:state", CircuitState::Closed->value);
}
public function recordFailure(): void
{
$failures = $this->redis->incr("cb:{$this->serviceKey}:failures");
if ($failures >= $this->failureThreshold) {
$this->redis->set("cb:{$this->serviceKey}:state", CircuitState::Open->value);
$this->redis->expire("cb:{$this->serviceKey}:state", $this->openTimeoutSeconds);
}
}
}
3. Configuring thresholds correctly instead of guessing arbitrarily
A failure threshold set too low opens the breaker on every short, harmless blip, making the service unreachable more often than necessary and needlessly lowering availability. A threshold set too high, on the other hand, lets too many failed requests through before the breaker intervenes at all, so the protective effect kicks in too late.
A proven practice is to define the threshold not as an absolute failure count but as a failure rate within a sliding time window (for example, more than 50 percent failures out of at least 10 requests in the last 60 seconds), so it sensibly covers both low-traffic and high-traffic applications. These values should be calibrated based on observed, real failure rates in production, not a generic library default.
4. Fallback strategies for the open state
A circuit breaker alone only solves half the problem: once the breaker is open, the application still needs to react sensibly to the missing response. Possible fallback strategies range from cached, slightly stale data (like the last known product price from a cache) to a degraded but functional mode (like search without personalization) to a clear error message to the user.
Which fallback strategy makes sense depends heavily on the business context: for a pricing service, a slightly stale cached price may be acceptable, but not for a payment service, where a clear error and a later retry is the only defensible path. This decision should be made explicitly and not implicitly left to the circuit breaker code.
5. Combining a circuit breaker with Symfony's HttpClient
Symfony's HttpClient component has no built-in circuit breaker, but combines well with a custom implementation or a dedicated library like ekino/php-circuit-breaker by placing the circuit breaker check before every HttpClient call and reporting the result back afterward. A clean approach wraps this logic in a decorator around HttpClientInterface so the rest of the application uses the circuit breaker transparently, without calling it explicitly.
It is important to keep circuit breaker state separate per external service, not global across all outgoing HTTP calls. An outage of payment service A should not also block the independent shipping service B, which is why the Redis key in the example above explicitly includes the `serviceKey`.
6. Why circuit breaker and retry complement rather than replace each other
Retry logic and circuit breakers solve different problems and are often mistakenly treated as alternatives. Retry helps with short-lived, transient failures (a single lost network packet, a brief garbage collection pause on the target service) by trying again after a short delay. For a persistently down or overloaded service, though, repeated retries only make the problem worse, since they add even more load to the already overloaded service.
A circuit breaker prevents exactly this escalation by completely stopping calls to the service after a certain failure count. The sensible combination is therefore: retry for individual, short-lived failures within the closed state, circuit breaker as the higher-level protection that steps in on persistent failures before retry attempts make the situation worse.
7. Making circuit breaker state changes observable
A circuit breaker that unnoticed stays stuck in the open state can serve fallback data for days without anyone noticing that the actual service has long since become available again, if the half-open check is misconfigured. Every state change should therefore be logged and ideally sent as a metric to a monitoring system like Prometheus, with alerts for extended open phases.
A dashboard that shows the current circuit breaker state and its change history per external service makes the application's resilience layer visible instead of treating it as invisible infrastructure. This matters especially because a persistently open breaker is a clear symptom of a deeper problem with a dependency service, one that deserves its own attention.
8. Deliberately testing circuit breaker behavior
A circuit breaker's behavior cannot be meaningfully validated by unit tests of the state transitions alone, because the actual goal is how the entire application behaves under a simulated outage of an external service. Integration tests that make an HTTP mock server deliberately return errors or timeouts check whether the breaker actually opens after the configured threshold and whether the fallback path kicks in correctly.
It is also worth running a deliberate, chaos-engineering-style test in a staging environment, where a real dependency service is temporarily artificially slowed down or disabled, to observe whether the application reacts as expected, instead of relying solely on the correctness of the unit tests.
9. Circuit breaker at a glance
The table below summarizes the core aspects of the pattern to distinguish it from related resilience patterns.
| State | Behavior | Trigger for transition |
|---|---|---|
| Closed | All requests pass through normally | Failure rate exceeds threshold → Open |
| Open | Requests rejected immediately, fallback applies | Timeout period elapses → Half-Open |
| Half-Open | Limited test requests let through | Success → Closed, failure → back to Open |
| Fallback (while Open) | Cache, degraded mode, or error message | Stays active until return to Closed |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Circuit Breaker: The Essentials at a Glance
Core problem
Without a circuit breaker, workers pile up on a slow dependency service until the whole application is blocked.
Three states
Closed lets everything through, Open blocks immediately with fallback, Half-Open cautiously tests recovery.
Complements retry
Retry handles short-lived failures, circuit breaker prevents retry from further loading an already overloaded service.
Monitoring mandatory
Every state change must be visible, otherwise fallback data can stay active unnoticed for days.