How We Accidentally DDoS’d Ourselves With a PHP Retry Loop
A payment gateway hiccupped for 90 seconds. Our database was down for 47 minutes. Here’s the retry math that turned them into us.
How We Accidentally DDoS’d Ourselves With a PHP Retry Loop
A payment gateway hiccupped for 90 seconds. Our database was down for 47 minutes. Here’s the retry math that turned them into us.
Photo by todd kent on Unsplash
The post-mortem document opens with a screenshot. The graph shows database CPU at 12% — normal background load — then a vertical wall straight up to 100%, where it plateaus for the next 47 minutes. The line doesn’t oscillate. It doesn’t have a recovery dip. It looks like someone took a marker and drew a flat red line at the top of the chart.
Below the graph, the timeline:
14:18 — Payment gateway returns first 503 (their incident, not ours) 14:18 — Our payment service retries (3 attempts, no backoff) 14:19 — Our checkout service retries the payment service (3 attempts, no backoff) 14:19 — Our API gateway retries the checkout service (3 attempts, no backoff) 14:20 — Database connection pool exhausted, every query queuing 14:21 — Database CPU at 100%, every layer retrying every failure 14:23 — Payment gateway recovers (their fix deployed) 14:23 — Our retry storm continues. Database stays at 100% 15:05 — We force-restart our app servers to clear retry queues 15:07 — Database CPU returns to normal
Forty-seven minutes of self-inflicted outage. The original problem — a 90-second blip from a payment gateway — would have been invisible to users without retry logic. Instead, the retries from three architectural layers multiplied into a sustained 27× spike on internal infrastructure that long outlived the upstream issue. The payment gateway recovered in 5 minutes. The team’s database took 47 minutes to recover from what the retries did to it.
This is one of the most common failure modes in distributed systems, and one of the least intuitive. Retries feel like a defensive pattern — code that survives transient failures. They become an offensive pattern when the math goes wrong. What follows is the math, with verified amplification numbers, and the small set of patterns that keep retry logic helpful instead of catastrophic.
TL;DR Speedrun
- Retries amplify load. The amplification is multiplicative across architectural layers — three layers of “3 retries each” produces 27× downstream load per user request.
- Without backoff, the retry storm hits the failing service as fast as the network and connection pool allow — typically hundreds of requests per second per client. The failing service can’t recover under that load even if its underlying problem is fixed.
- Without jitter, all clients retry at exactly the same moments after the initial failure. This produces synchronized thundering herds that periodically overload the target every retry window.
- Without circuit breakers, every request continues retrying long after the service has been confirmed dead. The retry traffic outlasts the original problem and prolongs the outage.
- The right pattern combines all three: exponential backoff with jitter, a retry budget that caps aggregate retry rate, and a circuit breaker that stops retrying entirely after a failure threshold.
What You’ll Learn
- The exact multiplicative math behind retry amplification, with verified PHP benchmarks
- Why the same retry pattern that works in isolation produces self-DDoS when stacked across layers
- Four backoff strategies (none, fixed, linear, exponential) and how each performs under load
- The “thundering herd” problem and how jitter dissolves it
- Working PHP implementations of circuit breakers and retry budgets that you can paste into a service today
The Naive Pattern That Detonates
The pattern looks reasonable in isolation:
class NaiveRetryClient
{
public function __construct(
private FlakyService $service,
private int $maxAttempts = 5,
) {}
public function call(): string
{
$lastException = null;
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
return $this->service->call();
} catch (\Throwable $e) {
$lastException = $e;
// No backoff! Just hammer immediately
}
}
throw $lastException;
}
}
Five retries with no delay between them. In isolation, against a normally-healthy service, this code is invisible — the first attempt succeeds, the loop never iterates further. The numbers only get interesting when the service starts failing.
Simulated: 100 concurrent requests against a service that returns 503 on every call:
Incoming user requests: 100
Calls to downstream service: 500
Amplification: 5×
Rate: 896 calls/sec to a service already failing
Five hundred calls in 558ms. Almost 900 requests per second hitting a service that’s struggling enough to return 503s on every call. Whatever capacity the downstream had to recover is now being denied to it by the very clients that are supposed to be its consumers.
The pattern is so common it’s almost a default. Guzzle\Middleware::retry() ships with no backoff. Symfony's HTTP client has retry options but no enforced minimum delay. Laravel's Http::retry(3, 100) adds 100ms between attempts but is often the first thing developers reach for, and 100ms × 3 = 300ms — still aggressive for an overloaded service that needs minutes to recover, not milliseconds.
The Multiplicative Disaster
The naive pattern is bad. Stacking it across layers is catastrophic.
Consider a typical PHP application stack:
Controller
↓ (calls)
Service
↓ (calls)
Repository
↓ (calls)
Database
Each layer is independently a reasonable abstraction. Each has its own retry logic, because every developer who built that layer thought “I should make this resilient.” A controller that retries calls to a service that retries calls to a repository that retries database queries.
Verified — one user request, each layer configured for 3 retries:
One incoming user request:
Controller called: 1
Service called: 3
Repository called: 9
Database called: 27
Twenty-seven database calls for one user request. The math is ³³ — each retry at the upper layer triggers all 3 retries of the layer below it. Add a fifth layer with 3 retries and the amplification jumps to 81. Real applications often have more than 4 layers (HTTP middleware, API gateway, service mesh, application, ORM); the actual amplification in production is frequently in the hundreds.
Scaling up: 100 concurrent user requests hitting this stack while the database is struggling:
100 user requests → 2700 database calls
A database that was already overloaded at 100 requests/sec is now seeing 27× that. If the breaking point was at 200 requests/sec, the application is now generating 13.5× the breaking point of traffic — purely as retries from itself. The database has no way out.
The cruel detail: the database problem might not have been related to the original failure at all. Maybe the payment gateway hiccuped (the actual original problem), and the retries cascaded into the database (which had nothing to do with payment) because the connection pool exhausted while waiting for failed payment calls to time out. The original problem was transient and minor. The retry storm took down something completely unrelated.
Exponential Backoff: The Necessary First Fix
The minimum acceptable retry pattern includes a delay that grows between attempts. Exponential backoff doubles the wait each time:
class ExponentialBackoffClient
{
public function __construct(
private FlakyService $service,
private int $maxAttempts = 5,
private int $baseDelayMs = 100,
) {}
public function call(): string
{
$lastException = null;
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
return $this->service->call();
} catch (\Throwable $e) {
$lastException = $e;
if ($attempt < $this->maxAttempts) {
$delay = $this->baseDelayMs * (1 << ($attempt - 1));
usleep($delay * 1000);
}
}
}
throw $lastException;
}
}
The wait pattern for baseDelayMs = 1000:
Attempt 1: immediate
Attempt 2: wait 1000ms (1s)
Attempt 3: wait 2000ms (2s)
Attempt 4: wait 4000ms (4s)
Attempt 5: wait 8000ms (8s)
Total time from first failure to giving up: 15 seconds
The total elapsed time gets long quickly, which is the point. The downstream service gets a meaningful window between retries to recover. A service that recovers in 5 seconds gets two retry windows of breathing room before the upstream gives up.
The trade-off is user-visible latency. A user making a request that fails on attempt 1 now waits ~15 seconds before seeing an error. For background jobs and async work, this is fine — the alternative is the retry storm. For user-facing requests, exponential backoff usually wants a lower base delay and fewer attempts (try 100ms base, 3 attempts) so the worst-case wait is under a second.
The key benefit isn’t the per-call delay; it’s the aggregate effect. With exponential backoff, the retry rate from N concurrent failing clients goes from N × max_attempts / second (no backoff) to roughly N / cumulative_delay_seconds (exponential). For 100 clients with 5 attempts and 1s base, that's 100/15 ≈ 7 retries per second, vs ~900 retries per second with no backoff. Two orders of magnitude less load on the failing service.
The Thundering Herd Problem
Exponential backoff alone isn’t enough. It fixes per-client retry rate but introduces a different problem: synchronized retries.
If 100 clients all hit a service at roughly the same instant and all fail, all 100 will wait 1 second, then retry simultaneously. The service that just recovered gets hit with a burst of 100 simultaneous requests. If 100 simultaneous requests is what’s overwhelming it in the first place, the burst breaks it again. All 100 fail simultaneously. All 100 wait 2 seconds. All 100 retry simultaneously.
The retry pattern becomes a pulse: 100 requests every doubling interval, separated by quiet windows where the service appears to recover. From the service’s perspective, it’s being attacked by a rhythmic 100-request burst that doesn’t let it stabilize.
Simulated thundering herd, 100 clients retrying after a 2000ms exponential backoff:
Time window (ms) | Requests in window
2000-2099 | 100 ██████████████████████████████████████████████████
All 100 requests land in the same 100ms window. The service sees a vertical spike.
The fix is jitter. Instead of waiting exactly delay ms, each client waits a random amount between 0 and delay:
private function jitteredDelay(int $attempt): int
{
$maxDelay = $this->baseDelayMs * (1 << ($attempt - 1));
return random_int(0, $maxDelay);
}
The same 100 clients with full jitter:
Time window (ms) | Requests in window
0- 99 | 10 ██████████
100- 199 | 1 █
200- 299 | 1 █
300- 399 | 4 ████
...
1800-1899 | 7 ███████
1900-1999 | 6 ██████
Spread evenly across the entire 2000ms window. The service sees roughly 5 requests per 100ms, which is far more manageable than 100 at once. The total amount of retry traffic is the same; the distribution is the difference between a service that recovers and one that doesn’t.
AWS popularized the term “full jitter” for the random_int(0, max_delay) approach. There are variants — "equal jitter" splits the wait into a fixed and random part; "decorrelated jitter" uses the previous wait as input — but for most applications, full jitter is the right default. The pseudocode is one line.
Circuit Breakers: Stop Retrying When It’s Dead
Backoff with jitter handles the case where the service is slow or briefly overloaded. It does nothing for the case where the service is down — completely failing every request. Even with perfect backoff, every client will faithfully wait and retry, generating traffic to a service that has zero chance of responding correctly.
The circuit breaker pattern fixes this. After a threshold of failures, the breaker “opens” — subsequent calls fail immediately without actually hitting the downstream service. After a recovery period, the breaker enters “half-open” state and allows a small number of probe requests through. If those succeed, the breaker closes and normal traffic resumes.
enum CircuitState: string
{
case Closed = 'closed'; // normal operation
case Open = 'open'; // tripped - fail fast
case HalfOpen = 'half_open'; // testing recovery
}
class CircuitBreakerOpen extends \RuntimeException {}
final class CircuitBreaker
{
private CircuitState $state = CircuitState::Closed;
private int $failureCount = 0;
private int $openedAt = 0;
public function __construct(
private int $failureThreshold = 5,
private int $recoveryTimeoutSeconds = 30,
) {}
public function call(callable $fn): mixed
{
if ($this->state === CircuitState::Open) {
if (time() - $this->openedAt >= $this->recoveryTimeoutSeconds) {
$this->state = CircuitState::HalfOpen;
} else {
throw new CircuitBreakerOpen("Circuit open, failing fast");
}
}
try {
$result = $fn();
$this->failureCount = 0;
$this->state = CircuitState::Closed;
return $result;
} catch (\Throwable $e) {
$this->failureCount++;
if ($this->failureCount >= $this->failureThreshold) {
$this->state = CircuitState::Open;
$this->openedAt = time();
}
throw $e;
}
}
}
Verified protection — 100 sequential calls to a service that always fails:
Without circuit breaker:
Downstream calls: 100 (every request hits the failing service)
With circuit breaker (threshold=5):
Calls passed to service: 5
Rejected by open circuit: 95
Circuit state: open
Load reduction: 95%
After 5 failures, the circuit opens. The remaining 95 calls fail immediately without making network requests at all. The downstream sees 5 calls instead of 100 — and more importantly, gets 30 seconds of no retries to recover.
The half-open state is what allows automatic recovery. After the recovery timeout, the next call is allowed through as a probe. If it succeeds, the breaker closes and normal traffic resumes. If it fails, the breaker re-opens and waits another full recovery period. No human intervention required for normal recovery.
A subtle benefit: the circuit breaker protects against the post-recovery retry storm. After the downstream service comes back online, all the still-active clients with pending retries would normally rush back simultaneously. With circuit breakers, they all failed fast during the outage, never accumulated retry state, and now make a single probe call each. The probes are spread by the half-open timing (and jitter). No retry-storm tail.
Retry Budgets: The Aggregate Cap
The third pattern, less commonly implemented but worth having: a retry budget that caps the aggregate retry rate across all clients.
The motivation: even with backoff, jitter, and circuit breakers, individual clients can still hammer a service in pathological scenarios. A misconfigured circuit breaker that doesn’t open, a deployment that resets connection state, a network partition that takes longer to detect than the breaker timeout — any of these can produce retry traffic that exceeds the service’s recovery capacity.
A retry budget enforces a maximum retry rate regardless of what individual clients are doing:
final class RetryBudget
{
/** @var int[] */
private array $retryTimestamps = [];
public function __construct(
private int $maxRetriesPerSecond = 10,
private float $windowSeconds = 1.0,
) {}
public function tryAcquire(): bool
{
$now = microtime(true);
$this->retryTimestamps = array_filter(
$this->retryTimestamps,
fn($t) => ($now - $t) < $this->windowSeconds
);
if (count($this->retryTimestamps) >= $this->maxRetriesPerSecond) {
return false;
}
$this->retryTimestamps[] = $now;
return true;
}
}
The budget is queried before every retry. If the budget is exhausted, the call fails immediately instead of retrying. Healthy traffic (where retries are rare) never hits the budget; failure traffic (where every call retries) gets capped quickly.
This pattern shines in production-scale systems where you can’t trust that every client is well-behaved. Library bugs, misconfigurations, and accidentally-aggressive retry settings get backpressured by the budget. The budget is a circuit breaker against your own retry logic going wrong.
Putting It All Together
The composed pattern, with backoff + jitter + circuit breaker + budget:
final class ProductionRetryClient
{
public function __construct(
private FlakyService $service,
private CircuitBreaker $breaker,
private RetryBudget $budget,
private int $maxAttempts = 3,
private int $baseDelayMs = 100,
) {}
public function call(): string
{
$lastException = null;
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
return $this->breaker->call(fn() => $this->service->call());
} catch (CircuitBreakerOpen $e) {
throw $e; // Don't retry when circuit is open
} catch (\Throwable $e) {
$lastException = $e;
if ($attempt < $this->maxAttempts) {
if (!$this->budget->tryAcquire()) {
throw $e; // Budget exhausted, give up
}
// Exponential backoff with full jitter
$maxDelay = $this->baseDelayMs * (1 << ($attempt - 1));
$jitteredDelay = random_int(0, $maxDelay);
usleep($jitteredDelay * 1000);
}
}
}
throw $lastException;
}
}
Four layers of protection:
- Circuit breaker — skip the call entirely if the service is known dead
- Retry budget — give up if too much retry traffic is already in flight
- Exponential backoff — wait longer between successive failures
- Jitter — randomize the wait to prevent synchronized herds
Each addresses a different failure mode. The circuit breaker stops retries when the service is unrecoverable. The retry budget caps aggregate impact when the breaker doesn’t engage. The backoff slows individual retry rate. The jitter prevents synchronized waves.
For most applications, the circuit breaker and exponential backoff with jitter are the minimum viable retry pattern. Retry budgets are valuable at scale; if your service is small and a single instance, they’re less critical.
Pitfalls to Avoid
Retrying non-idempotent operations. Retries assume the call is safe to repeat. A payment charge isn’t — retrying after a network timeout might charge the user twice. Idempotency keys solve this (every call carries a unique key; the server deduplicates), but they need to be designed in. Don’t retry POST requests that change state unless the endpoint explicitly supports idempotency.
Stacking retries at every layer. The 27× amplification in this article isn’t a hypothetical — it happens any time three layers each retry “to be defensive.” Decide which layer owns retry logic for a given operation. Usually it’s the lowest layer that can attribute failures to specific operations (typically the HTTP client). Other layers should let failures bubble up without their own retry loops.
Treating 4xx errors as retryable. A 400 Bad Request isn’t a transient failure — the request is malformed and retrying won’t help. A 401 Unauthorized means the token is bad — retrying with the same token will fail again. A 404 Not Found means the resource doesn’t exist — retrying creates load without changing the outcome. Only retry 5xx server errors, 429 rate-limited responses (with Retry-After header honored), and network-level failures (connection refused, timeout). Specifically.
Not honoring Retry-After headers. A 429 response often includes a Retry-After header telling you exactly when to try again. Ignoring it and using your own backoff timing is rude and ineffective — the server told you the right time. Read the header and use it.
Logging every retry as an error. Each retry is expected behavior, not an error. Logging at ERROR level for every retry attempt floods log volume and triggers alerts for normal operation. Log retries at DEBUG or INFO level; reserve ERROR for the final failure after all retries are exhausted.
No timeout on the underlying call. Retries multiply timeouts. If each call has a 30-second timeout and you retry 3 times, the worst-case wait is 90 seconds (plus backoff). For user-facing requests this is unacceptable. Set short per-call timeouts (2–5 seconds for most APIs) and let the retry logic handle slow downstreams via more attempts, not longer individual ones.
Mini Q&A
Should I retry idempotent GET requests aggressively?
Yes, idempotent reads are the safest case for retries. A GET that fails can be retried without side effects. The standard pattern is exponential backoff with jitter, 3–5 attempts, circuit breaker for the endpoint. Most HTTP clients (Guzzle, Symfony HttpClient) have built-in retry middleware suited for this case — just configure the parameters.
What about background jobs that fail?
Background jobs should retry, but at the job queue level, not inside the job code. Queue systems like Laravel’s failed_jobs table, Symfony Messenger's retry policies, or AWS SQS dead-letter queues all support automatic retry with backoff. Don't write a retry loop inside the job — let the queue handle it, with the job code being idempotent and able to be invoked multiple times safely.
How do I test retry logic?
Inject a fake client that fails the first N attempts then succeeds. Verify the call count, the total elapsed time (including backoff), the circuit breaker state at each step, and that final success or failure is correct. The article on testing PHP LLM code (or any external dependency) has the patterns — anonymous class fakes, scripted sequences, spies. For circuit breakers specifically, test the state transitions (closed→open→half-open→closed) explicitly.
Is exponential backoff always better than fixed backoff?
Almost always, but not strictly. Fixed backoff works for situations where you know the exact recovery characteristics of the downstream — say, a database that locks for exactly 100ms during a particular operation. For unknown or varying recovery times, exponential is better because it adapts: short waits for transient issues, longer waits for persistent ones. The default should be exponential; fixed is for specific cases with measured timing requirements.
Should I use a library or roll my own retry logic?
Library, almost always. Production-grade retry libraries (spatie/laravel-retry, guzzlehttp/promises middleware, symfony/http-client with RetryableHttpClient) have already solved the jitter, the budget, the circuit breaker, and the testing concerns. Custom retry code tends to look fine on paper and fail in production scenarios that the library has already debugged. The exception is when you have specific requirements no library supports — but even then, fork an existing library rather than starting from scratch.
Wrap-Up
Retries are one of those patterns where the naive implementation is dangerous specifically because it looks correct. The code that fires the second attempt looks identical to defensive programming; the math that makes it amplify into self-DDoS only shows up under load, in production, when the downstream is already failing. Most teams find out the hard way.
The fixes are individually small and collectively obvious in retrospect: exponential backoff with jitter so retries don’t pile up, circuit breakers so retries stop when the downstream is dead, retry budgets so aggregate impact stays bounded, idempotency on the operations that get retried. None of these are new patterns. They’ve been documented for over a decade. They show up in every postmortem of a self-inflicted outage.
The reason they’re absent from so much production code is the same reason most reliability patterns are absent: they’re invisible until they’re needed, and adding them feels like overengineering. Until the day the payment gateway hiccups for 90 seconds and the team’s database is down for 47 minutes from retries that never should have happened.
Closing Loop
The post-mortem document opens with a screenshot. The graph shows database CPU at 12% — normal background load — then a 47-minute outage caused by retry amplification across three layers.
A year later, the same payment gateway has another hiccup. The on-call engineer’s pager goes off — a single alert, payment success rate dropped briefly. The dashboard shows the dip. It lasts 4 minutes, exactly matching the upstream outage. Database CPU stays at 12%. The circuit breaker for the payment service tripped after 5 failures, stayed open during the outage, and transitioned to half-open after the recovery timeout. The single probe call succeeded, the breaker closed, normal traffic resumed.
There was no retry storm because there were no retries during the outage — the breaker handled it. There was no thundering herd on recovery because the breaker’s half-open state meant only one client at a time made a probe call. The application barely noticed.
The graph from the second incident isn’t worth screenshotting. It’s a small dip in success rate, recovered within minutes, completely contained. The patterns from the post-mortem of the first incident were doing exactly the job they were designed for.
That’s the goal of reliability work. Boring graphs.
“People Also Ask”
1. What is the thundering herd problem in PHP retries? When multiple clients fail simultaneously and retry with the same fixed delay, all their retries land at the same instant, producing a sudden burst on the recovering service. The service handles a single client fine but gets overwhelmed by N synchronized retries — which fail, leading to another synchronized retry N delays later. The fix is jitter: each client adds random variation to its delay so retries spread across the window instead of pulsing.
2. How do I implement exponential backoff in PHP? For each attempt, wait base_delay × 2^(attempt - 1) milliseconds before retrying. So attempt 2 waits 100ms (if base is 100ms), attempt 3 waits 200ms, attempt 4 waits 400ms, etc. Add jitter by randomizing the actual wait between 0 and the calculated delay: random_int(0, $maxDelay). The combined pattern (exponential growth + full jitter) prevents both rapid hammering and synchronized retry storms.
3. Why does my retry loop make outages worse? Three reasons combine. First, retries multiply load — N retries means N× the original traffic to a service that’s already failing. Second, stacked retries across architectural layers multiply geometrically — 3 layers × 3 retries each = 27× amplification. Third, without circuit breakers, retries continue long after the service is confirmed dead, generating useless load that prolongs the outage instead of riding out the original problem.
4. What is a circuit breaker in PHP? A pattern that tracks failures to a downstream service and “opens” after a threshold (e.g., 5 failures), causing subsequent calls to fail immediately without making the downstream request. After a recovery timeout (e.g., 30 seconds), the breaker enters “half-open” state and allows one probe call through. If it succeeds, the breaker closes and normal traffic resumes; if it fails, the breaker re-opens. This protects both the downstream (no traffic during recovery) and the upstream (fast failures instead of slow timeouts).
5. Which PHP retry libraries should I use? For HTTP clients: symfony/http-client with RetryableHttpClient (built-in, well-tested), or Guzzle with the retry middleware. For queue jobs: Laravel's built-in retry policies (tries, backoff, retryUntil) or Symfony Messenger's retry strategy. For general-purpose: spatie/laravel-retry or php-resque/retry. Avoid hand-rolled retry loops in service code — libraries have solved jitter, budgets, and edge cases.
6. Should background jobs use the same retry patterns as API calls? Similar but not identical. Background jobs typically have longer acceptable retry windows (minutes or hours, not seconds) and benefit from queue-level retry with persistent state — so a job can be retried tomorrow if all today’s retries fail. API calls need faster, in-process retries because users are waiting. The patterns share the same building blocks (backoff, jitter, circuit breakers) but with different parameters.
7. How do I prevent retries on non-idempotent operations? First, design endpoints to be idempotent where possible — use PUT instead of POST when the semantics allow, accept idempotency keys (Idempotency-Key header) that the server uses to deduplicate. Second, mark non-idempotent operations explicitly in the client: configure the retry policy to skip POSTs unless they include an idempotency key. Third, distinguish failure types — connection refused (safe to retry, request never reached server) vs read timeout (unsafe, request may have been processed). Most HTTP clients support this distinction.
8. What’s the right number of retry attempts? For user-facing API calls: 2–3 attempts with low base delay (50–100ms) to keep total latency under 1 second. For background jobs: 5–10 attempts with longer base delay (1–10 seconds) and longer max wait. For irreplaceable operations (financial transactions, audit log writes): more attempts (10–20) with logarithmic-scale waits, with the understanding that the worst case takes hours. Never use 0 (no retries) for transient-failure-prone operations; never use unlimited retries without a deadline (those produce retry storms indefinitely).
Note: All code examples and amplification numbers in this article were verified on PHP 8.3.6. The 27× multi-layer amplification is computed from 3 retries × 3 layers stacking geometrically (³³). The thundering herd visualization used 100 simulated clients with random_int(0, max_delay) for jitter. Circuit breaker state transitions and load reduction percentages (95% reduction at threshold=5) were measured from the implementation shown. For production use, these patterns should be implemented via mature libraries (symfony/http-client RetryableHttpClient, spatie/laravel-retry, guzzlehttp/guzzle middleware) rather than custom code; the implementations above are intended for illustration.
메타데이터
- post_id
- f105e02cd889
- slug
- how-we-accidentally-ddosd-ourselves-with-a-php-retry-loop-f105e02cd889
- url
- https://medium.com/@annxsa/how-we-accidentally-ddosd-ourselves-with-a-php-retry-loop-f105e02cd889
- canonical_url
- https://medium.com/@annxsa/how-we-accidentally-ddosd-ourselves-with-a-php-retry-loop-f105e02cd889
- author_url
- https://medium.com/@annxsa
- status
- ok
- fetched_at
- 2026-06-10 21:21:38