PhoenixKit.API.Idempotency (phoenix_kit v2.64.1)

Copy Markdown View Source

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 call store/2 with the JSON body of the answer, but only when storable?/1 says it may be kept; otherwise call release/1.
  • {:replay, body} - the same request already finished; send body again.
  • {: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

claim()

@type claim() :: %{uuid: String.t(), locked_at: DateTime.t()}

Functions

claim(scope_ref, key, request_hash, now \\ DateTime.utc_now())

@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.

credential_bearing?(body, keys \\ [])

@spec credential_bearing?(String.t(), [String.t()]) :: boolean()

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.

prune(now \\ DateTime.utc_now())

@spec prune(DateTime.t()) :: non_neg_integer()

Deletes the rows that have expired. Returns how many.

redact_credentials(data, keys \\ [])

@spec redact_credentials(term(), [String.t()]) :: term()

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.

release(map)

@spec release(claim()) :: :ok

Gives a claim up: deletes the row if it is still this claim and still running.

storable?(body, keys \\ [])

@spec storable?(String.t(), [String.t()]) :: boolean()

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).

store(map, body)

@spec store(claim(), String.t()) :: :ok | :lost

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.