NAME

Punk::Plugin::Idempotency - Idempotency on unsafe methods

SYNOPSIS

package MyApp;
use Punk;

cache 'file', dir => '/var/cache/app';

plugin 'Idempotency' => {
    scope => sub { $_[0]->current_user->{id} },
    ttl   => 86400,
};

post '/orders' => sub { ... }, { idempotent => 1 };

DESCRIPTION

A client that sends POST /orders and loses the connection cannot know whether the order was created. It has two choices and both are bad: retry and risk two orders, or give up and risk none. An Idempotency-Key removes the choice - the server recognises the retry and replays the first response.

What this guarantees, and what it does not

A retry carrying the same key, within the TTL, that reaches a worker able to see the store, after the first request's response was recorded, replays that response instead of executing the work again.

Every clause there is load-bearing, and the last one is a window. Between the handler committing the order and the entry reaching the store, this plugin provides nothing: a process killed in that gap leaves an order created and no key recorded, so the retry executes and creates a second one - the exact failure the plugin exists to prevent, in the one situation where a client is most likely to retry.

Cache-backed idempotency collapses that window. It does not remove it. On this machine the gap is about three microseconds - the after-dispatch chain plus one store write - so the process has to die inside a three-microsecond window that occurs once per idempotent request. That is a risk an operator can reason about, which is why it is a number here rather than an adjective.

Two things widen it, and both are yours: a slow store, and anything you add to the after-dispatch chain, because every hook there runs inside the gap.

Closing it entirely needs the key written inside the same transaction as the work, which means the store has to be the database - and this plugin does not own your handler's transaction. See "The store is a seam" for what that would take.

The scope is yours, and there is no default

plugin 'Idempotency' => { scope => sub { $_[0]->current_user->{id} } };

Required. The plugin croaks at boot without it.

The stored value is a whole response - somebody's order, with their address in it. If two accounts can produce the same cache key, this plugin becomes a way to read other people's responses by guessing a UUID, on exactly the endpoints worth reading. Scoping it is the thing that prevents that, and the plugin cannot do it for you: a Punk application may authenticate through a session, an auth identity, an OAuth2 token or an API key, and picking one would be silently wrong for the rest.

Whatever the coderef returns is the scope. Returning undef means the plugin cannot say whose key this is, and it will not invent an answer: the request proceeds as though the plugin were not there, nothing is stored, and a warning is logged. An application that wants idempotency for anonymous callers - a signup, a payment carrying its own token - returns something itself, which puts that decision with the person able to make it safely.

What a key is scoped to

Three things, and each has a reason:

  • The scope, above.

  • The route, as declared - /orders/:id, not /orders/7 - so the same key against two endpoints does not replay one endpoint's answer for the other. The method is in there with it, because POST /orders and DELETE /orders are different operations a client could reasonably key the same way.

  • The request itself. The entry records a fingerprint - the method, the route and the body - and a retry whose fingerprint does not match gets a 422. A key reused with a different body is a client bug, and a silent replay is the worst possible answer to it: the client would believe its second, different order had succeeded when it never ran.

The key is request bytes

Idempotency-Key must be 1 to 255 printable ASCII characters with no space. Anything else is a 400, refused rather than repaired - trimming a bad key into a good one means two different client keys can become one server key, which is the collision all of the above exists to prevent. The key is hashed before it reaches the store, so no input reaches a cache key, let alone a path.

Which responses are replayed

2xx, 3xx and 4xx are recorded. 5xx is not: a 5xx is transient by construction, and replaying one turns a single bad minute into a permanent bad day for the life of the TTL - the client retries, gets the stored 500 instantly, retries again, and the endpoint is broken for that key until it expires. A 4xx is recorded, because a 422 for a malformed order is a real answer about that request.

A response whose body is not an arrayref of strings - a send_file, a stream - cannot be recorded without consuming what is being sent, so it is served normally and not recorded. That means no idempotency on that request, so it is logged at warn.

A replay carries Idempotency-Replayed: true. A client that cannot tell a replay from a fresh execution cannot debug anything, and neither can your log.

A stored Set-Cookie is not replayed - the one deliberate exception to "return what they got". That cookie carries the first request's session state, and handing it to a retry that may arrive from a newer session writes back a stale one.

Guards run first

The replay happens between a route's guards and its handler, never before them. A replay returns a stored response body, so answering ahead of the guards would hand somebody else's order to a caller the guard was about to refuse. An unauthorised retry carrying a perfectly valid key gets your guard's answer.

Two retries at once

A client whose connection dropped often retries more than once. Punk::Cache's single-flight lock is what makes that safe: the first execution holds it, and a concurrent retry waits and replays rather than executing in parallel. A retry that waits out the budget executes anyway - a stalled request is worse than a duplicated one, which is the rule compute already follows.

The plugin reads through the memory tier, never from it. A tier is a copy per worker, eventually consistent, and a tier that has not yet seen a write answers "no entry" - which here means "execute the work a second time". So memory => ... on your cache is honoured for everything else and bypassed for these keys.

The store is a seam

Everything above reaches the store through three operations: read, write with a TTL, and a best-effort lock. Any Punk::Cache backend works, including one from outside this distribution.

That is deliberate. A database-backed store - the thing that would close the window, by writing the key inside your transaction - implements those same three, so it is a drop-in rather than a rewrite of this plugin.

OPTIONS

scope

Required, a coderef receiving the context. See above.

ttl

Seconds an entry is replayable, default 86400. How long after a failed request will your client still retry is a business question, so this is an option rather than a constant.

ROUTE OPTIONS

idempotent => 1 opts a route in. It is per route because a key honoured on every POST means a store write on every POST, and most POSTs do not need one. Inert unless this plugin is registered; on a route that never receives a key it costs about 26 nanoseconds.

Only POST, PUT, PATCH and DELETE are affected. GET is already idempotent, and caching it is Punk::Cache or Punk::Plugin::ConditionalGet.

A request with no Idempotency-Key proceeds normally. Requiring one is an API design decision you make in a guard, not one a plugin imposes.

SEE ALSO

Punk, Punk::Cache for the store and its single-flight lock, Punk::Queue for work that must not be lost rather than merely not repeated.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)