Imagine your online store calls an external payment API every time a customer places an order.
You already added timeouts. Your application no longer waits forever when the provider gets slow.
But now a customer clicks “Pay,” the request times out, and your app shows an error.
You think: “Easy. I’ll just retry.”
The second attempt succeeds. The customer receives their confirmation, and everything looks fine.
Until they check their bank account.
They were charged twice.
What went wrong? Was the payment provider broken? Did the customer click twice?
Not necessarily. The first request may have processed the payment successfully, but the response never reached your application. Your retry created another charge.
And duplicate payments are only one problem. If hundreds of customers retry immediately during an outage, your application sends even more traffic to a service that is already struggling.
Does that mean you should never retry?
Not at all. A temporary failure can disappear by the next attempt. You just need to teach your application three rules: repeat safely, retry selectively, and know when to wait or stop.
Here’s how.
Tip 1: Make the operation safe before you retry it
A timeout means your application did not receive an answer in time. It does not prove that the operation failed.
Think of ordering a pizza over the phone. You place your order, but the call drops before the restaurant confirms it.
If you call again and place a completely new order, you might receive two pizzas.
Instead, you ask about the order you already placed.
An idempotency key gives an API a way to recognize that you are repeating the same operation.
For a provider that supports the Idempotency-Key header, a request could look like this:
use Illuminate\Support\Facades\Http;
// Load an existing payment attempt from your database.
// Its unique key and payload were saved before the first API call.
$response = Http::withHeaders([
'Idempotency-Key' => $paymentAttempt->idempotency_key,
])
->connectTimeout(1)
->timeout(2)
->post('https://payments.example.com/charge', [
'order_id' => $paymentAttempt->order_id,
'amount_minor' => $paymentAttempt->amount_minor,
'currency' => $paymentAttempt->currency,
]);
$response->throw();The rule is simple: the same logical payment attempt uses the same key and the same payload, including after a job restarts or the customer refreshes the page.
Create the key once and persist it before sending the request. Don’t generate a new UUID every time you retry.
Your checkout must also reuse that pending payment attempt instead of creating another one for every click.
The provider must explicitly support idempotency. Adding a header alone does not prevent duplicate charges. Check its retention window and replay rules; Stripe, for example, documents both. [1]
If the outcome is unknown, keep the payment pending and reconcile it through the provider’s status API or webhooks before initiating another charge.
Tip 2: Retry temporary failures, not every error
Imagine the payment API rejects a request because a required field is missing.
Will sending the same payload three more times fix it?
No. You need to correct the request.
A retry should have a reason to succeed.
| Failure | What to do |
|---|---|
| Connection failure or timeout | Consider a bounded retry if repeating the operation is safe |
| 502, 503, or 504 | Possible retry candidates; follow the provider’s contract |
| 400 or 422 | Correct the request |
| 401 or 403 | Resolve authentication or permissions |
| 429 | Respect the provider’s rate-limit policy and Retry-After guidance |
| Card declined | Follow the payment result; don’t blindly repeat the charge |
Laravel accepts a retry condition as the third argument to retry(). Here’s a small policy for a read-only product lookup:
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
$shouldRetry = function (\Throwable $exception): bool {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array(
$exception->response->status(),
[502, 503, 504],
true
);
};
$response = Http::connectTimeout(1)
->timeout(2)
->retry(3, 200, $shouldRetry)
->get('https://catalog.example.com/products/123');Here, 3 means three total attempts, including the first request. The delay is in milliseconds. [2]
This example deliberately excludes 429. If the provider asks you to wait longer than your request budget allows, schedule the work for later instead of sleeping inside the customer’s request.
For payments, combine a provider-specific retry policy with Tip 1. A retryable error alone does not make a write safe to repeat.
Tip 3: Add backoff and jitter, and keep a retry budget
Imagine a crowded room where everyone asks the same question at once.
Nobody hears an answer, so everyone immediately asks again.
Louder.
That is what aggressive retries can do to an overloaded API.
Backoff increases the delay between attempts. Jitter varies that delay so clients are less likely to retry together. Keep both the delay and the attempt count bounded. [3]
Replace the fixed delay in Tip 2 with this callback, keeping the same $shouldRetry policy:
$response = Http::connectTimeout(1)
->timeout(2)
->retry(
3,
function (int $attempt, \Throwable $exception): int {
$baseDelay = min(1000, 200 * (2 ** ($attempt - 1)));
return $baseDelay + random_int(0, 100);
},
$shouldRetry
)
->get('https://catalog.example.com/products/123');For this example:
- The first retry waits 200–300 milliseconds.
- The second retry waits 400–500 milliseconds.
- After three failed attempts, the application stops.
But notice the total waiting time.
Three attempts with a two-second timeout, plus up to 800 milliseconds of delays, can consume roughly 6.8 seconds, before other application work and overhead.
A two-second timeout is a per-attempt limit, not a deadline for the entire operation.
Choose the attempt count, timeouts, and delays together. If the customer’s request cannot afford that wait, reduce the attempts or move suitable work to a queue. These synchronous delays still occupy a worker.
Also check for retries elsewhere. Three queue executions with three HTTP attempts each can produce nine calls. Set a budget across the whole operation. [3]
When attempts are exhausted, Laravel can throw a RequestException or ConnectionException. Handle that final failure in your application. [2]
For a catalog lookup, you might show cached information. For an uncertain payment, show a pending state and reconcile the result. Don’t tell the customer the charge failed when you don’t know.
Three small changes, a safer integration
| Tip | What it does |
|---|---|
| 1. Make repetition safe | Helps prevent duplicate operations when the provider supports idempotency |
| 2. Retry selectively | Avoids repeating requests that need correction |
| 3. Use bounded backoff and jitter | Spreads retries and limits extra traffic and waiting |
Before shipping, test a temporary failure followed by success, a validation error that must not retry, and repeated timeouts that must stop. For payments, also test that the same pending operation retains its idempotency key.
A retry doesn’t repair a broken provider. It gives a temporary failure another chance to recover—within limits your application can afford.
These are the decisions that help you build software people trust. They also give you concrete examples to discuss when interviewing for roles involving complex integrations.
Subscribe for practical insights on building resilient software and developing the skills to work on interesting projects.
Let’s build software that keeps working when things go wrong.