Idempotent writes for the machine 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 client-token hash for public
calls whose tool declares a client token. 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, names a credential
(PhoenixKit.API.Auth.credential_name?/1: token, secret, api_key, authorization,
bearer, or any name ending in _token, compared without regard to case), or is one of
the output fields the tool declares (PhoenixKit.API.Tool.credential_keys/1: its
credentials and its client_token), or any string value starts with pk_.
PhoenixKit.API.Legacy adds the keys claim_url and checkout_url and the value
marker /checkout/claim/ for modules that predate Tool.credentials.
Module authors: name a field that carries a secret *_token, or list it in the tool's
credentials, 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. token_count or tokens_issued
are not credentials; session-token (a hyphen) is not either.
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); keys are the tool's own
credential fields. 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;
keys are the tool's own credential fields.
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. keys are the
tool's own credential fields (PhoenixKit.API.Tool.credential_keys/1).
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); keys are the tool's own
credential fields. 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;
keys are the tool's own credential fields.
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. keys are the
tool's own credential fields (PhoenixKit.API.Tool.credential_keys/1).
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 client token or a claim URL stored here would
sit in the table in plain text.