Skip to content

Idempotency and retries

Agents retry on network errors. Without idempotency, a retry can cause duplicate writes. PerSQL caches the response of any request that includes an Idempotency-Key for 24 hours.

await db.query(
"INSERT INTO events (id, name) VALUES (?, ?)",
["e-1", "click"],
{ idempotencyKey: "evt-e-1-2026-04-29" }
);

If the request succeeds, a retry with the same key (within 24 hours) returns the cached response without re-running the SQL. The HTTP response includes Idempotency-Replayed: true so you can tell.

Good keys are:

  • Stable across retries — pick something derived from the operation itself (e.g. evt-${eventId} or order-${orderId}-charge).
  • Unique to that operation — don’t reuse keys across different writes.
  • Short — capped at 200 characters.

A common pattern in agent loops is to derive the key from the model’s tool_use_id:

await db.query(input.sql, input.params, {
idempotencyKey: toolUse.id,
});

Idempotency-Key covers a single request. An agent’s plan is usually a sequence — schema migration, then seed, then verify. If step 3 of a 5-step plan fails and the agent retries, you don’t want steps 1 and 2 to re-run.

Plan-Key + Plan-Step give you sequence-level idempotency. Pair a plan id (stable across the plan) with a step id (stable per step within the plan):

const planKey = `migrate-${migrationId}`;
await db.query(step1Sql, [], { planKey, planStep: "create-tables" });
await db.query(step2Sql, [], { planKey, planStep: "seed-defaults" });
await db.query(step3Sql, [], { planKey, planStep: "backfill" });

On retry, every step that already returned 2xx replays from cache; failed and never-reached steps re-run. The HTTP response carries Plan-Replayed: true on cache hits.

Storage shape: per-step KV row keyed by (tokenId, planKey, stepId), 24-hour TTL. Keys are namespaced by api token, so a leaked plan-key can’t hijack a different bearer’s plan.

There is no per-token throughput cap — the prepaid balance is the spend control. A coarse per-IP flood control at the unauthenticated edge can return 429 with a Retry-After header for anonymous floods; the SDK throws a RateLimitError you can catch:

import { RateLimitError } from "@persql/sdk";
try {
await db.query("SELECT 1");
} catch (e) {
if (e instanceof RateLimitError) {
await new Promise((r) => setTimeout(r, e.retryAfterSeconds * 1000));
// retry…
}
}