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/42 can be called repeatedly and should return the same resource state unless something else changes it.
  • PUT /users/42 replaces a known resource, so repeating the same request should leave the resource in the same state.
  • DELETE /users/42 should leave the resource deleted even if called twice.

POST is usually not naturally idempotent because it often creates a new server-chosen resource.

PHP example
<?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 example
<?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 example
<?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-After where available.
  • Do not retry invalid input or authentication failures.
  • Require idempotency for operations with side effects.
PHP example
<?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 example
<?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.