Guzzle retry middleware: retry a paid POST without paying twice
Guzzle's Middleware::retry takes a decider and a delay in milliseconds. Retry only transient answers, honor Retry-After, and keep one idempotency key.

Guzzle's retry middleware is Middleware::retry($decider, $delay), pushed onto the client's HandlerStack. The decider gets the retry count, the request, and the response or exception, and returns true to retry; the delay function returns milliseconds to wait, and without one Guzzle uses exponential backoff. For a paid POST, retry only transient answers and send an idempotency key, because the middleware resends the same request.
Guzzle facts come from its Handlers and Middleware, Request Options and Quickstart pages and the 7.9 source of Middleware.php and RetryMiddleware.php, where the retry middleware is documented. The example API's retry rules come from Sume's Errors and spend and Create a run pages. All were read on 2026-09-29.
How does Middleware::retry decide and wait?
- The decider has no built-in attempt cap. Return
falseonce$retriesreaches your limit. http_errorsdefaults totrue, so 4xx and 5xx responses become exceptions on their way up the stack, and a retry middleware pushed on top sees the exception, not the response. Sethttp_errorstofalsefor this call to hand the response, with itsRetry-After, to the decider and the delay.
| Piece | What it does |
|---|---|
$decider | Called with the number of retries, the request, the response and the exception; returns true to retry |
$delay | Called with the retry number, the response and the request; returns milliseconds |
| Default delay | exponentialDelay: 2^(retries − 1) × 1000 ms, so 1 s, 2 s, 4 s |
| What it resends | The same request object, headers included |
Which answers should the decider retry?
Network failures and answers where resending the same request can succeed. For Sume's run create, that is 409 idempotency_key_in_use (wait about a second), 429 rate_limited (wait retry-after) and 503 studio_agent_upstream_unavailable (retry with the same key). A blanket >= 500 rule is wrong here: 502 attachment_fetch_failed means an input URL couldn't be fetched. 409 idempotency_conflict and 402 insufficient_credits fail the same way every time, and a 4xx at create means nothing ran and nothing was charged. Retryable HTTP status codes covers the general rules.
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\RetryMiddleware;
use Psr\Http\Message\ResponseInterface;
$decider = function (int $retries, $request, ?ResponseInterface $response, $exception): bool {
if ($retries >= 4) return false;
if ($exception instanceof ConnectException) return true;
if (!$response) return false;
$status = $response->getStatusCode();
return $status === 429 || $status === 503
|| ($status === 409 && str_contains((string) $response->getBody(), 'idempotency_key_in_use'));
};
$delay = function (int $retries, ?ResponseInterface $response): int {
if ($response && $response->hasHeader('Retry-After')) {
return (int) $response->getHeaderLine('Retry-After') * 1000;
}
return RetryMiddleware::exponentialDelay($retries) + random_int(0, 1000);
};
$stack = HandlerStack::create();
$stack->push(Middleware::retry($decider, $delay));
$client = new Client(['handler' => $stack, 'base_uri' => 'https://api.sume.com/']);
$response = $client->post('v1/formats/acme/product-promo/runs', [
'http_errors' => false, 'timeout' => 30,
'headers' => ['Authorization' => 'Bearer ' . getenv('SUME_API_KEY'),
'Idempotency-Key' => 'order-8823-promo-v1'],
'json' => ['instruction' => '15-second vertical promo.'],
]);Why does the idempotency key make the retry safe?
Because the retry is for the case where you can't tell whether the server acted, such as a timeout after it accepted the request. The middleware resends the same request, so the same Idempotency-Key goes out each time, and Sume answers a same-key, same-body replay with 200, the original run and idempotency_hit: true: no second run, no second charge. Derive the key from the order and a version, never from the time; Sume's docs say a per-request UUID makes the header decorative.
Check $response->getStatusCode() after the call: 202 is a fresh run, 200 a replay. Once you hold either, poll the run or wait for its webhook instead of calling create again. A run that later ends failed needs a new key.
What is Guzzle's RequestException?
The parent of the exceptions Guzzle throws for HTTP error responses: ClientException for 4xx and ServerException for 5xx, both through BadResponseException, thrown while http_errors is true. A ConnectException, thrown for networking errors, is not a RequestException; both extend TransferException. A catch (RequestException $e) therefore misses connection failures. The timeout and connect_timeout options default to 0, which waits indefinitely, so set timeout on every call you retry.
Sources
Related posts
More in Integrations
- HubSpot custom coded actions: call an outside API safely
A HubSpot custom code action runs Node.js or Python in a workflow for 20 seconds, with secrets as env vars. Start slow API jobs there; don't wait.
- IFTTT webhooks: call an API that needs an API key
IFTTT's Webhooks service can send any web request with custom headers, on Pro plans. Route paid API calls through your own endpoint.
- Looping by Zapier: send one API request per item in a list
Looping by Zapier runs every later action once per list value, all in parallel, up to 500 times. Pace and key paid API calls to match.
- Make.com error handling: retry a paid API call without duplicates
Make.com has five error handlers; Retry stores the failed bundle and reruns it. Send a fixed Idempotency-Key so a rerun can't bill twice.
Written by Sume