An SMS provider slowed down — it didn’t even crash, its response time just climbed to 30 seconds. Within half an hour the entire application became unresponsive; even endpoints with nothing to do with SMS were timing out. One slow external service had dragged the whole system down with it.
An external service will fail sooner or later. That’s not the question. The question is: will your system go down with it?
How cascading failure happens
The logic is simple and merciless. A slow external call locks up the worker that makes it — or the PHP-FPM process. If the call takes 30 seconds, that process can’t look at any other request for 30 seconds.
Requests pile up, the process pool fills. Once the pool is full — including requests with nothing to do with SMS — new requests wait too. Your healthy endpoints die because of one sick dependency. This is called cascading failure, and it almost always starts with a slowdown, not a crash.
First defense: timeout
The first and cheapest defense is to give every external call an explicit and short timeout. Default timeouts — 30 seconds or more in most HTTP clients — are unacceptable for production.
$response = Http::timeout(5) // total time
->connectTimeout(2) // connection establishment time
->get('https://sms-provider.example/send');
The rule from the Laravel queue post applies here too: not the default 30 seconds, but 5. Don’t leave to chance how long a process waits on a dependency — that number should be a decision.
Second defense: retry — but carefully
Retry makes sense for transient errors, but blind retry does harm. Immediately retrying a failing service piles more load onto it — this is called a retry storm; it knocks the service back down before it can fully recover.
If you retry, two things are mandatory: exponential backoff (increasing wait on each attempt) and jitter (random offset — so all clients don’t retry at the same moment).
Http::retry(3, 200, function ($exception) {
return $exception instanceof ConnectionException;
})->timeout(5)->get($url);
Keep the retry count low. If there’s still an error after three attempts, the problem isn’t transient; continuing to retry only ties up your own process for longer.
Third defense: circuit breaker
A timeout caps a single call; a circuit breaker decides to stop making calls at all. Its logic comes from an electrical fuse, and it has three states:
- Closed — everything’s normal, calls pass through. Failures are counted.
- Open — most of the last N calls failed; the circuit opens. The external service is no longer hit at all, and the call returns an error instantly.
- Half-open — after a while the circuit allows a single trial call. If it succeeds, it returns to
closed; if not, back toopen.
The gain: when the external service is sick, you don’t wait on it. Even a 5-second timeout is 5 seconds per process; in the open state that drops to 0. Fail-fast beats slow-fail.
// Conceptual flow — leave the threshold, counter, and timer to a mature package.
if ($breaker->isOpen('sms')) {
return $this->queueForLater($message); // circuit open: no attempt at all
}
try {
$response = Http::timeout(5)->post($smsUrl, $payload);
$breaker->recordSuccess('sms');
} catch (\Throwable $e) {
$breaker->recordFailure('sms'); // circuit opens if the threshold is exceeded
return $this->queueForLater($message);
}
Instead of writing this logic from scratch, use a mature package — getting the state counting, the threshold, and the timer right is subtler than it looks.
Fallback: what to do when the circuit is open
A circuit breaker’s value shows up in what you do in the open state. “Return an error” is the weakest option. Better ones:
- Queue it, send it later. If the SMS doesn’t have to go out immediately, drop it on a queue to be processed once the circuit closes.
- Serve stale but valid data. If an exchange-rate service is down, showing the last known rate beats showing none.
- Degrade the feature gracefully. A “Recommendations can’t load right now” message beats crashing the whole page.
Which fallback is right depends on the business — but “no fallback” is not an answer.
When don’t you need all this?
Not every external call wants a circuit breaker. A timeout is always mandatory. Retry, only if the operation is idempotent. A circuit breaker mainly adds value when the call is frequent and the service is on the user’s path — for an integration called a few times a day, a timeout and a reasonable retry are usually enough.
An external service failing isn’t a question of “if,” it’s a question of “when.” Going down with it, though, is a design choice — and a choice you can change.
Don’t leave your system as fragile as your weakest dependency.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.