http clients and apis
General API Idempotency And Retry Design
Idempotency means the same operation can be repeated without creating a second side effect. In API work, it is what allows clients to retry after a timeout without accidentally creating two orders, two payments, two tickets, or two emails.
Retries and idempotency belong together. A retry policy answers "when should the client try again?" Idempotency answers "what happens if the first attempt actually worked but the client never saw the response?"
Natural idempotency
Some HTTP methods are normally idempotent by design:
GET /users/42can be called repeatedly and should return the same resource state unless something else changes it.PUT /users/42replaces a known resource, so repeating the same request should leave the resource in the same state.DELETE /users/42should leave the resource deleted even if called twice.
POST is usually not naturally idempotent because it often creates a new server-chosen resource.
<?php
declare(strict_types=1);
function naturallyIdempotent(string $method): bool
{
return in_array(strtoupper($method), ['GET', 'HEAD', 'PUT', 'DELETE'], true);
}
foreach (['GET', 'POST', 'PUT', 'DELETE'] as $method) {
echo $method . ': ' . (naturallyIdempotent($method) ? 'yes' : 'no') . PHP_EOL;
}
// Prints:
// GET: yes
// POST: no
// PUT: yes
// DELETE: yes
That does not mean every real implementation is perfect. For example, a DELETE endpoint that sends a cancellation email every time it is called is not idempotent in practice.
Idempotency keys
For create-style POST operations, clients can send an idempotency key. The server stores the key with the first response. If the same key arrives again, the server returns the stored response instead of running the operation again.
<?php
declare(strict_types=1);
function createPayment(array $request, array &$idempotencyStore): array
{
$key = trim((string) ($request['idempotency_key'] ?? ''));
if ($key === '') {
return ['status' => 400, 'body' => ['error' => 'idempotency_key is required']];
}
if (isset($idempotencyStore[$key])) {
return $idempotencyStore[$key];
}
$response = [
'status' => 201,
'body' => [
'payment_id' => 'pay_' . (count($idempotencyStore) + 1),
'amount' => $request['amount'],
],
];
$idempotencyStore[$key] = $response;
return $response;
}
$store = [];
$request = ['idempotency_key' => 'order_123_payment', 'amount' => 1999];
print_r(createPayment($request, $store));
print_r(createPayment($request, $store));
// Output includes the same payment_id both times.
In a real API, the idempotency key should usually be scoped to the authenticated account and endpoint. Two different users should not collide because they chose the same key.
Request fingerprinting
A common safety rule is: the same idempotency key must not be reused with a different request body. Store a fingerprint of the original request and reject mismatches.
<?php
declare(strict_types=1);
function requestFingerprint(array $request): string
{
ksort($request);
return hash('sha256', json_encode($request, JSON_THROW_ON_ERROR));
}
$first = ['amount' => 1999, 'currency' => 'GBP'];
$second = ['currency' => 'GBP', 'amount' => 1999];
$third = ['amount' => 2999, 'currency' => 'GBP'];
var_dump(requestFingerprint($first) === requestFingerprint($second));
var_dump(requestFingerprint($first) === requestFingerprint($third));
// Prints:
// bool(true)
// bool(false)
Do not use fingerprints as a substitute for validation. Validate the request first, then store enough information to prove that a repeated key is the same logical operation.
Retry design
Good retry design needs limits:
- Retry temporary failures only.
- Use a maximum attempt count.
- Use backoff, and respect
Retry-Afterwhere available. - Do not retry invalid input or authentication failures.
- Require idempotency for operations with side effects.
<?php
declare(strict_types=1);
function canClientRetry(string $method, int $statusCode, bool $hasIdempotencyKey): bool
{
if (!in_array($statusCode, [408, 429, 502, 503, 504], true)) {
return false;
}
if (naturallyIdempotent($method)) {
return true;
}
return strtoupper($method) === 'POST' && $hasIdempotencyKey;
}
var_dump(canClientRetry('POST', 503, false));
var_dump(canClientRetry('POST', 503, true));
var_dump(canClientRetry('POST', 422, true));
// Prints:
// bool(false)
// bool(true)
// bool(false)
Server-side race conditions
Idempotency must be enforced atomically. If two identical requests arrive at the same time, both may check "does this key exist?" before either writes it. In real applications, use database constraints, transactions, locks, or a storage system with atomic insert-if-absent behaviour.
An in-memory array is fine for learning the idea, but it is not enough for production PHP running across multiple workers or servers.
What to check
Before moving on, make sure you can:
- Explain why a timeout can lead to duplicate operations.
- Identify which HTTP methods are naturally idempotent.
- Use an idempotency key for create-style operations.
- Reject reuse of the same key with a different request.
- Explain why idempotency storage must be durable and atomic.
Practice
Practice: Idempotent Create Endpoint
Write a small PHP function that models an idempotent create-style API endpoint.
Requirements
- Require an idempotency key.
- Store the first successful response for that key.
- Return the stored response when the same key and same request body arrive again.
- Reject the same key if the request body is different.
- Show examples for first request, repeated request, missing key, and key reused with a different body.
Show solution
This solution stores a fingerprint and response for each idempotency key. That lets a retry return the original result while still rejecting accidental key reuse.
<?php
declare(strict_types=1);
function fingerprint(array $body): string
{
ksort($body);
return hash('sha256', json_encode($body, JSON_THROW_ON_ERROR));
}
function createTicket(array $headers, array $body, array &$idempotencyStore): array
{
$key = trim((string) ($headers['Idempotency-Key'] ?? ''));
if ($key === '') {
return ['status' => 400, 'body' => ['error' => 'Idempotency-Key header is required.']];
}
$requestFingerprint = fingerprint($body);
if (isset($idempotencyStore[$key])) {
if ($idempotencyStore[$key]['fingerprint'] !== $requestFingerprint) {
return ['status' => 409, 'body' => ['error' => 'Idempotency key was reused with a different request.']];
}
return $idempotencyStore[$key]['response'];
}
$response = [
'status' => 201,
'body' => [
'ticket_id' => 'ticket_' . (count($idempotencyStore) + 1),
'subject' => $body['subject'] ?? 'Untitled',
],
];
$idempotencyStore[$key] = [
'fingerprint' => $requestFingerprint,
'response' => $response,
];
return $response;
}
$store = [];
$examples = [
createTicket(['Idempotency-Key' => 'abc123'], ['subject' => 'Login issue'], $store),
createTicket(['Idempotency-Key' => 'abc123'], ['subject' => 'Login issue'], $store),
createTicket([], ['subject' => 'Billing issue'], $store),
createTicket(['Idempotency-Key' => 'abc123'], ['subject' => 'Different issue'], $store),
];
foreach ($examples as $result) {
echo $result['status'] . ' ' . json_encode($result['body'], JSON_THROW_ON_ERROR) . PHP_EOL;
}
// Prints:
// 201 {"ticket_id":"ticket_1","subject":"Login issue"}
// 201 {"ticket_id":"ticket_1","subject":"Login issue"}
// 400 {"error":"Idempotency-Key header is required."}
// 409 {"error":"Idempotency key was reused with a different request."}
Real idempotency storage should be backed by a database or another atomic store. The important design rule is that retries get the same result and changed requests do not accidentally share a key.