PhoenixKit.API.Idempotency (phoenix_kit v2.63.0)

Copy Markdown View Source

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

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)

@spec credential_bearing?(String.t()) :: boolean()

Whether a JSON body carries a credential (see the moduledoc). 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(map)

@spec redact_credentials(term()) :: term()

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.

release(map)

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

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

storable?(body)

@spec storable?(String.t()) :: boolean()

Whether an answer may be stored: at most 1 MB, valid JSON, no credential.

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 cart token or a claim URL stored here would sit in the table in plain text.