Retries and idempotency
Repeat agent operations without duplicating effects or mistaking a replayed response for current state.
Idempotency is an attempt identity, not a freshness guarantee. A replay can prove what one earlier request returned. It cannot prove that the resource is still in that state.
Start with the installed CLI's machine-readable catalog:
farthershore operations list --format json
Read each operation's retry.kind, retry.keyRequired, retry.enforcement,
retry.responseSemantics, retry.reconcile, and retry.rationale before
automating it. The catalog also includes commands that are registered directly
instead of projected into MCP under directCliOperations; this includes remote
farthershore validate --business and subscriber service-account operations.
The retry contracts
retry.kind | Use an idempotency key? | Safe agent behavior |
|---|---|---|
read_current | No | Retry the read. Each successful response is a new observation. |
convergent_write | No | After an ambiguous result, run retry.reconcile first. Repeat only if the intent is still current, then reread. This avoids overwriting a newer change. |
same_key_replay | Yes, for the live request | Persist one key before the first dispatch. Reuse it only for that exact uncertain attempt, then reconcile. |
intrinsic_replay | No | The stable target already names the one transition. Repeat the exact request and reconcile; Core rejects a changed payload. |
no_automatic_retry | No | An ambiguous result may have caused an external effect. Stop and use retry.reconcile before deciding whether a new action is required. |
retry.enforcement states what actually supplies safety: a fresh read,
resource identity, Core's idempotency reservation, transactional secret replay,
or a server-derived transition identity. retry.responseSemantics makes the
freshness boundary explicit. In particular, original_attempt_result is
historical proof about one attempt, not current resource state.
intrinsic_replay is intentionally rare. Managed service-account approval is
one example: the approval ID and authenticated approver identify the transition,
so adding a random caller key would not make it safer.
For agent mutations, Core accepts Idempotency-Key only when the concrete
retry contract is same_key_replay. Sending one on a convergent write,
preview-only route, or no_automatic_retry operation returns
400 IDEMPOTENCY_KEY_NOT_SUPPORTED. Current-state reads expose no key in the
CLI/MCP contract and always execute as fresh reads; an incidental HTTP header
cannot turn a read into a cached response. These boundaries prevent a caller
from accidentally treating a desired-state write or observation as historical
replay state.
For a CLI-session or MakerToken principal, Core also rejects a live
same_key_replay request that omits the header with
400 IDEMPOTENCY_KEY_REQUIRED, before the operation handler runs. This
server-side requirement protects agents that call the HTTP API directly rather
than through the CLI or MCP. Shared human browser routes may remain unkeyed;
the browser flow is not an automatic agent retry contract.
Execute a same-key operation
- Resolve the organization, business, environment, and exact payload.
- Run
--dry-runwithout an idempotency key when preview is supported. - Generate and record a new opaque key in durable agent task state before the first live dispatch.
- Send the live request with
--idempotency-key <persisted-key>. - If the result is ambiguous, retry the byte-equivalent intent with the same key. Do not generate another key.
- Run the catalog's
retry.reconcileread before reporting current state.
When Core served the completed attempt from its replay store, the success envelope contains:
{
"meta": {
"idempotency": {
"replayed": true
}
}
}
This metadata is outside data, so it cannot be mistaken for a resource field.
Its absence means Core did not mark that response as a replay; it still does
not make a write response a fresh read.
Example:
# Record this value in private task state before the live call.
CREATE_ATTEMPT=$(node -e 'console.log(crypto.randomUUID())')
farthershore business create quillby \
--idempotency-key "$CREATE_ATTEMPT" \
--format json
farthershore business show quillby --format json
Do not store attempt keys in the business repository. They are not credentials, but they are operational correlation material and do not belong in product source.
What “the same attempt” means
A key is scoped to the authenticated organization and principal. Within that scope it names one semantic request across every endpoint. The following must remain identical on a retry:
- HTTP method and concrete path, including the target resource;
- environment selection;
- query values other than the preview control;
- canonical request body, including array order;
- any route-specific semantic target.
Reusing the key for a different intent returns
422 IDEMPOTENCY_KEY_REUSED; the second mutation does not run. A request still
executing returns 409 IDEMPOTENCY_KEY_IN_FLIGHT; back off and retry the same
key. If an attempt has remained in progress long enough that Core can no longer
prove whether its handler committed, it returns
409 IDEMPOTENCY_RESULT_INDETERMINATE with retryable: false. Core retains the
attempt and does not execute it again. Run the reconciliation read; use a new
key only after current state proves that a new mutation is still required.
Keys are retained for 24 hours.
A 5xx emitted after a keyed handler starts is also indeterminate: a server
error cannot prove that no database or provider effect committed. Core retains
the attempt instead of freeing the key for an automatic rerun. A 3xx/4xx
response is stored as the terminal historical result of that attempt too,
because a handler may have partially applied before rejecting. Reconcile first;
if corrected input or a fresh action is required, give that new intent a new
key.
Previews never consume a live attempt
Never send an idempotency key with --dry-run. The CLI and Core reject that
combination. Preview again from current state, then create a separate key for
the live operation. This prevents a preview response from being replayed as if
the write happened.
Only operations whose catalog entry has retry.preview: true expose preview.
Core returns 400 DRY_RUN_NOT_SUPPORTED for any other mutating route instead
of silently running a live mutation or returning a false preview.
Some APIs expose a dedicated POST preview route and reuse the live operation's
authorization permission. The route-level OpenAPI
x-farthershore-agent-retry contract is authoritative: dedicated agreement
preview routes are fresh observations and reject Idempotency-Key, while the
separate confirmed agreement create or amend route requires one.
One-time results
Token and credential mints use same_key_replay so an ambiguous first response
can recover the exact secret without minting another. The encrypted secret
recovery window is 15 minutes. After it expires, the completed attempt remains
tombstoned and returns IDEMPOTENCY_RESULT_EXPIRED; Core does not rerun the
mutation. Use the reconcile read to locate the created credential, then rotate
under a new persisted key if a new secret is actually required.
auth context-token is deliberately different. It mints a short-lived,
point-in-time authorization credential, so replay could return a token whose
expiry or embedded authorization snapshot is no longer useful. Its contract is
no_automatic_retry: after an ambiguous response, request a new token rather
than attaching an idempotency key or treating an older token as current state.
Externally delivered effects
webhook test and webhook trigger send a real signed HTTP request to the
configured receiver. Persist a key before the first send so an uncertain retry
cannot deliver the same test twice:
WEBHOOK_ATTEMPT=$(node -e 'console.log(crypto.randomUUID())')
farthershore webhook trigger quillby <webhookId> \
--type payment.failed \
--idempotency-key "$WEBHOOK_ATTEMPT" \
--format json
farthershore webhook deliveries quillby <webhookId> --format json
webhook listen remains no_automatic_retry because it is a process-owning,
multi-step session (tunnel, temporary endpoint, optional send, tail, cleanup),
not one server mutation that response replay can make atomic.
Desired-state examples
Backend binding is convergent and environment-specific, so it deliberately has no replay key. After an ambiguous bind, inspect the current binding before deciding whether to repeat it; do not blindly restore an older URL over a newer human or agent change:
farthershore backend list quillby --format json
# Select the row whose environmentId matches the resolved preview environment.
# If it is not already correct and the original intent is still current:
farthershore backend bind quillby api \
--env preview \
--origin-url https://preview-api.example.com \
--format json
farthershore backend list quillby --format json
Usage-limit create is naturally identified by subscription, subject, and
quantity. An exact repeated create returns the current matching row; if that row
changed after the first request, the retry returns conflict instead of stale
success. Updates target a stable limit ID, but an absolute count can still be
newer than the ambiguous request. Both paths therefore run limit list before
any repeat and again after a write.
Never add a key merely because a request uses POST. Use the advertised retry
contract and the observed operation semantics.