Idempotent writes for the shop API: a client that retries a request with
the same Idempotency-Key gets the first answer back, and never a second write.
claim/4 is called before the handler runs. Its answers:
{:claimed, claim}- this request runs. Afterwards callstore/2with the JSON body of the answer, but only whenstorable?/1says it may be kept; otherwise callrelease/1.{:replay, body}- the same request already finished; sendbodyagain.{:error, :in_progress}- the same request is running (its lease is alive).{:error, :reused}- the key was used for a different request.
A claim is a row of phoenix_kit_api_idempotency keyed by (scope_ref, key);
scope_ref is the API key's uuid for admin calls and the cart-token hash for public
cart calls. The insert is ON CONFLICT DO NOTHING, so two first requests cannot both
win. A request that crashed or timed out leaves an in_progress row; once its lease
(api_idempotency_lease_seconds) has run out a retry of the same request takes the row
over. A request that committed and died before store/2 runs again after a takeover,
so module authors should also give their writes a natural key of their own (an
external_ref, say) where a repeated write would do harm.
A claim is the pair {uuid, locked_at}. Every later write compares locked_at, so a
run that lost its claim to a takeover can neither store nor delete anything.
What is stored
Only a 200 body that is at most 1 MB and carries no credential (storable?/1).
Previews, confirmation prompts, pending approvals and errors are not stored: the caller
releases the claim, so the confirming or the corrected call may reuse the key. A body
is credential-bearing when any object key, at any depth, is one of cart_token,
confirm_token, claim_token, claim_url, checkout_url, api_key, access_token,
token or secret (compared without regard to case), or any string value starts with
pk_ or contains /checkout/claim/ (the marker is compared without regard to case).
Module authors: give a field that carries a secret one of those names, or the answer is
not safe to replay. The check is deliberately broad, so it has false positives: an echoed
SKU or title that starts with pk_, or a field named token or secret, makes the
answer unstorable, and the retry of that write then runs the handler again. Keep such
values out of write answers. The names are matched exactly: refresh_token or
cart-token are not on the list.
Summary
Functions
Claims key for the request request_hash in scope_ref. now is a parameter so
tests move the clock; any precision is accepted.
Whether a JSON body carries a credential (see the moduledoc). A body that is not JSON counts as carrying one: it is not stored.
Deletes the rows that have expired. Returns how many.
Redacts credentials in JSON-compatible data using the same rules as replay storage. Approval results and progress must pass through this before they are persisted.
Gives a claim up: deletes the row if it is still this claim and still running.
Whether an answer may be stored: at most 1 MB, valid JSON, no credential.
Stores the answer of a finished request. :lost when the claim was taken over (or
deleted) meanwhile: the newer run owns the row.
Types
@type claim() :: %{uuid: String.t(), locked_at: DateTime.t()}
Functions
@spec claim(String.t(), String.t(), String.t(), DateTime.t()) :: {:claimed, claim()} | {:replay, String.t()} | {:error, :in_progress | :reused}
Claims key for the request request_hash in scope_ref. now is a parameter so
tests move the clock; any precision is accepted.
Whether a JSON body carries a credential (see the moduledoc). A body that is not JSON counts as carrying one: it is not stored.
@spec prune(DateTime.t()) :: non_neg_integer()
Deletes the rows that have expired. Returns how many.
Redacts credentials in JSON-compatible data using the same rules as replay storage. Approval results and progress must pass through this before they are persisted.
@spec release(claim()) :: :ok
Gives a claim up: deletes the row if it is still this claim and still running.
Whether an answer may be stored: at most 1 MB, valid JSON, no credential.
Stores the answer of a finished request. :lost when the claim was taken over (or
deleted) meanwhile: the newer run owns the row.
The body is written as given. The caller must check storable?/1 first and call
release/1 instead when it is false: a cart token or a claim URL stored here would
sit in the table in plain text.