lucidAGENTS
Buy

Handle retries and idempotency

Retry transport attempts without duplicating charges, fulfillment, tasks, or downstream effects.

x402 already performs one expected protocol retry: an unpaid request receives a challenge, then the client resends a signed request. Application retries sit outside that exchange and must preserve one logical business identity.

The invariant

Generate one idempotency key before the first request and reuse it for every attempt that means “perform this exact operation once.” Never create a fresh key because the client timed out or received 503.

const operationId = `report:${reportId}:generation:v1`;

const response = await paidFetch(endpoint, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'idempotency-key': operationId,
  },
  body: JSON.stringify({ input: { reportId } }),
});

Lucid requires 20–256 characters for invoke keys. A key must not contain a private key, access token, email address, or other sensitive raw identifier.

What the server fingerprints

For invoke, Lucid scopes a key to the entrypoint/mode and fingerprints:

  • HTTP method and full URL;
  • request body;
  • the verified payment or SIWX subject when available;
  • relevant ambient authorization/cookie/API-key context when needed.

The same key and fingerprint returns the stored successful response with Idempotency-Replayed: true. The same key with a different request or subject returns 409 idempotency_key_conflict.

An in-progress duplicate returns 409 idempotency_in_progress plus Retry-After: 1. It is not permission to send a new key.

Retry decision table

ObservationPayment/fulfillment certaintyDefault action
Initial 402Not paid; expected protocol negotiationLet the x402 client validate, sign, and retry under the same key
400 invalid_request / invalid_inputLucid does not settleFix the request; a changed body is a new logical operation/key
401/403 authorization or policy denialNot admitted/settled by LucidStop; retry only after legitimate auth/policy change
402 after a signed retryCredential/settlement rejectedStop automatic retries; inspect requirement/version/network/asset/signature
409 idempotency_in_progressAnother attempt owns the operationWait Retry-After, then query/retry the same key
409 idempotency_key_conflictKey was used for different materialStop and investigate caller/key-generation bug
429 or transient pre-payment capacity responseNo successful result evidencedBack off and retry same operation/key within a bounded attempt budget
Handler 500Lucid does not settle, but handler may have external side effectsInspect downstream state; retry same key only when effects are idempotent/compensated
503 before a challengeConfiguration/store/provider unavailableDo not sign; back off with same key after health recovers
503 payment_recording_failed with settlement evidencePayment may be irreversible; local record incompleteReconcile/query same key; do not issue a new payment
Network timeout after signingUnknownQuery invoke/task/business/provider state first; retry only same key
200 with PAYMENT-RESPONSESettlement and successful response evidencedPersist receipt plus output; do not retry
200 without expected payment header on a priced callFulfillment response exists but settlement evidence is missingTreat as inconsistent; reconcile rather than assuming free or paid

HTTP status alone cannot distinguish every crash window. Keep both settlement evidence and fulfillment/task evidence.

Bounded retry loop

The payment-aware Fetch already handles the protocol challenge. Wrap the whole call only for transport/transient application attempts:

async function invokeOnce(
  paidFetch: typeof fetch,
  endpoint: string,
  operationId: string,
  input: unknown
): Promise<Response> {
  return paidFetch(endpoint, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'idempotency-key': operationId,
    },
    body: JSON.stringify({ input }),
  });
}

async function invokeWithBoundedRetry(
  paidFetch: typeof fetch,
  endpoint: string,
  operationId: string,
  input: unknown
): Promise<Response> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      const response = await invokeOnce(
        paidFetch,
        endpoint,
        operationId,
        input
      );

      if (response.ok) return response;
      if (response.status === 409) {
        const error = (await response.clone().json()) as {
          error?: { code?: string };
        };
        if (error.error?.code === 'idempotency_in_progress') {
          await new Promise(resolve => setTimeout(resolve, 1_000));
          continue;
        }
      }

      if (response.status < 500 || attempt === 2) return response;
    } catch (error) {
      if (attempt === 2) throw error;
      // Outcome may be ambiguous. A production caller should query its
      // business/task/provider state here before resending the same key.
    }

    await new Promise(resolve =>
      setTimeout(resolve, 250 * 2 ** attempt + Math.random() * 100)
    );
  }

  throw new Error('Retry loop exhausted');
}

This skeleton intentionally does not retry arbitrary 4xx, does not change the key, and caps attempts. Replace the timeout comment with an application-specific status/reconciliation query before production.

Server configuration

Target-side invoke idempotency is enabled by default with a bounded in-memory store. Configure it explicitly:

const runtime = await createAgent(meta)
  .use(
    http({
      idempotency: {
        store: durableIdempotencyStore,
        inProgressTtlMs: 15 * 60_000,
        retentionMs: 7 * 24 * 60 * 60_000,
      },
    })
  )
  .build();

Choose inProgressTtlMs longer than worst-case handler plus settlement time. Choose retention longer than client retry, support, queue, and reconciliation windows. An expired in-progress claim can allow another execution; a completed record that expires can allow a later duplicate.

The store must atomically claim, complete, and release across all replicas. A separate in-memory store per replica is not idempotency for a load-balanced service.

Downstream idempotency

Lucid can prevent the canonical invoke handler from running twice only while its target claim is valid. The handler must pass the same operation identity to databases, queues, model jobs, email, webhooks, and other paid APIs. Use a unique constraint, transactional outbox, provider idempotency key, or equivalent at every irreversible boundary.

This matters because invoke fulfillment runs before settlement. A settlement failure can follow a handler side effect.

Streaming and task differences

The HTTP idempotency store applies to invoke, not the stream or task-create routes.

  • A stream settles when its SSE response is admitted. After receiving a run ID or any event, reconnect/resume through an application protocol rather than opening a new paid stream blindly.
  • A task settles after durable task reservation and returns { taskId, accessToken }. Persist that access capability before doing anything else; poll the existing task after a timeout.
  • If the task-create response itself is lost, the current Lucid task profile does not provide target-side creation idempotency. Add an application submission identifier/store when duplicate accepted tasks are unacceptable.
  • Scheduler jobs derive a stable key for each occurrence; a durable scheduler store must preserve it through lease recovery.

Reconciliation record

For each logical operation, persist:

  • your business operation ID and Lucid idempotency key;
  • target URL, entrypoint, mode, normalized network, expected recipient/amount;
  • request fingerprint or immutable request version;
  • run/task ID and terminal fulfillment state;
  • PAYMENT-RESPONSE or MPP receipt reference and external transaction ID;
  • local payment/accounting state and last retry/error;
  • timestamps and safe trace correlation IDs.

Do not persist raw payment credentials, private keys, task access tokens in general logs, or unredacted bodies by default.

Tests

Run same-key concurrent calls, different-body conflict, different-subject conflict, claim expiry during a slow handler, store disconnect at claim and complete, settlement success plus record failure, handler side effect plus settlement failure, process kill at every boundary, and replay after restart.

Read the normative payment transaction model and configure durable storage before enabling automatic retries.

On this page