Skip to main content

Idempotency

A client that sends a write and gets no answer cannot know whether it acted. Sending it again risks doing it twice: two orders, two charges, two messages. With an Idempotency-Key, the client sends the same key with every attempt, and the server acts on the first one only. This follows draft-ietf-httpapi-idempotency-key-header.

Protect a route​

Give the route the middleware, after the middleware that authenticates the request. A key belongs to the signed-in user, and before authentication there is none, so every request with a key would be answered with 500:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Pushery\HttpApi\Idempotency\Idempotent;

Route::post('/api/orders', function (Request $request) {
// Runs once per key, however often the client repeats the request.
return response()->json(['ordered' => $request->input('item')], 201);
})->middleware(['auth:sanctum', Idempotent::class]);

A client sends the key as a header, written with or without quotes. A random UUID for each new request is a good key; the example below uses a short one:

POST /api/orders HTTP/1.1
Idempotency-Key: "order-1"
Content-Type: application/json

{"item": "book"}

What the client gets​

The requestThe answer
the first one under a keythe route's own answer
the same request again, same keythe first answer again, with Idempotent-Replayed: true
the same key with a different request422, idempotency-key-reused
the same key while the first request still runs409, idempotency-key-in-flight
the same key after the store could not keep the first answer409, idempotency-answer-not-kept
a POST or PATCH without a key400, idempotency-key-missing
a key that is too long or not printable ASCII400, idempotency-key-malformed

Two requests are the same when their method, path, query and body match, the fields and files of a form included. Only an answer below 400 is kept: a request that was refused or failed frees its key, so the client can send the corrected request under the same key.

PUT and DELETE need no key, because they come to the same end however often they are sent, but one they carry is held like any other. GET, HEAD and OPTIONS pass through untouched. Where a route should take a key without requiring one, give it the optional parameter:

use Illuminate\Support\Facades\Route;
use Pushery\HttpApi\Idempotency\Idempotent;

Route::post('/api/messages', function () {
return response()->noContent();
})->middleware(Idempotent::class.':optional');

A repeated request gets back the status, the body and the headers listed in http-api.idempotency.replayed_headers, by default Content-Type, Location, Content-Location, ETag and Last-Modified. A cookie is never replayed, whatever the list says.

Whose key it is​

A key is only unique for the client that chose it, so the package keeps the keys of each client apart. By default a key belongs to the signed-in user. An API that knows its callers by something else, an API key, a signed service or a tenant, binds its own resolver:

use Illuminate\Http\Request;
use Pushery\HttpApi\Idempotency\ResolvesIdempotencyScope;

final class ApiKeyScope implements ResolvesIdempotencyScope
{
public function scopeOf(Request $request): ?string
{
$key = $request->attributes->get('api_key_id');

return is_string($key) ? 'api-key:'.$key : null;
}
}
use App\Http\ApiKeyScope;
use Pushery\HttpApi\Idempotency\ResolvesIdempotencyScope;

$this->app->bind(ResolvesIdempotencyScope::class, ApiKeyScope::class);

A request that carries a key on a route where the resolver finds nobody is answered with 500, idempotency-scope-missing, and the exception reaches your error reporting. That is a gap in the setup, not in the request: the package does not fall back on the client's address, because clients behind the same proxy would then see each other's answers.

Where the keys are kept​

Keys and answers live in a cache store, and the store has to meet three conditions:

  • add() is atomic, so two requests under one key cannot both act: redis, database, memcached, dynamodb and array keep it atomic, the file store does not.
  • It evicts nothing. A store that drops entries under memory pressure drops a taken key or a kept answer, and the next retry acts a second time. Redis does that under every maxmemory-policy but noeviction, the volatile-* policies included, because every entry here carries a TTL. Keep the keys in a Redis instance whose policy is noeviction, or in the database store.
  • It holds the whole answer. An answer is kept as it was sent, body and headers. memcached keeps an item of up to 1 MB unless it is started with a larger -I, and DynamoDB one of up to 400 KB. A streamed answer and a file download have no body to keep at all. Where an answer cannot be kept, the request still gets it and the key stays used for ttl: a repeat is answered with 409, idempotency-answer-not-kept, instead of acting a second time. A store that throws on the write is reported to your error reporting as well.

Name the store in http-api.idempotency.store, or leave it at the default store.

An answer is kept for ttl seconds, a day by default, which outlasts every retry a client makes. While the first request under a key runs, the key is taken for in_flight_ttl seconds, ten minutes by default. A process that dies midway therefore frees its key again after that time instead of holding it for a day. Set in_flight_ttl above the longest time a write may take.