# `PhoenixKit.API.Idempotency`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.64.1/lib/phoenix_kit/api/idempotency.ex#L1)

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.

# `claim`

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

# `claim`

```elixir
@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?`

```elixir
@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`

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

Deletes the rows that have expired. Returns how many.

# `redact_credentials`

```elixir
@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`

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

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

# `storable?`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
