Retry Policy

Retry Policy

The API is happiest when you retry the right failures and surface the rest. The wrong policy can turn a transient blip into a duplicate write — or worse, a partial state on chain.

Which codes to retry

CodeRetry?Why
429 rate_limitedYesQuota will reset on the next minute / day boundary
500 internal_errorYesUsually a transient upstream (Supabase, Helius, Privy) hiccup
400 bad_requestNoThe payload is wrong; retrying changes nothing
401 unauthorizedNoAdd the header and resend, but it's not a "retry"
403 forbiddenNoKey or scope problem — fix the key, then resend
404 not_foundNoThe resource doesn't exist; retrying won't conjure it
409 conflictNoIdempotency-Key collision — use a fresh key

Recommended backoff

Exponential delay starting at 1 s, doubling each attempt, with full jitter:

attempt 1 → wait 1 s  ± jitter
attempt 2 → wait 2 s  ± jitter
attempt 3 → wait 4 s  ± jitter
attempt 4 → wait 8 s  ± jitter
  • 429: retry up to 4 times (total ~15 s of waiting).
  • 5xx: retry up to 3 times (total ~7 s).
  • Any other 4xx: do not retry; surface the error to the caller.

Jitter means picking a uniform random value in [0, base_delay] instead of waiting the full computed delay — it prevents thundering-herd retries from your own fleet.

async function withRetry<T>(fn: () => Promise<Response>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    const res = await fn();
    if (res.ok) return res.json();

    const body = await res.clone().json().catch(() => ({}));
    const code = body?.error?.code;
    const retriable = res.status === 429 || res.status >= 500;
    const cap = res.status === 429 ? 4 : 3;
    if (!retriable || attempt >= cap) throw new Error(`API ${res.status} ${code}`);

    const delayMs = Math.random() * 1000 * 2 ** attempt;
    await new Promise((r) => setTimeout(r, delayMs));
  }
}

Pair retries with Idempotency-Key

Network timeouts are the trap. The server may have succeeded while your client gave up waiting — without an idempotency key, your retry will execute the action twice: two pool creations, two fee claims, two airdrop inits.

Always send Idempotency-Key on write requests:

curl -X POST https://api.piratecrew.fun/v2/pools/POOL_ADDRESS/fee-claims \
  -H "Authorization: Bearer $PIRATE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "split", ... }'

The middleware persists (key, request_hash, response) in Supabase. On a replay:

  • Same key + same body -> the original 2xx response is returned, no on-chain action is re-executed.
  • Same key + different body -> 409 conflict. Use a fresh v4 UUID for a different request.

Generate the key once per logical operation and reuse it across the entire retry sequence — not a new key per attempt. A common pattern is to derive it from your own request ID, e.g. Idempotency-Key: req_abc123.

See Idempotency for the storage details and edge cases.

The SDK does this for you

The official @piratecrewfun/pirate-sdk ships with this policy on by default:

const sdk = new PirateSDK({
  apiKey: process.env.PIRATE_API_KEY!,
  secretKey: process.env.WALLET_SECRET_KEY!,
  maxRetries: 3,   // default — 5xx + 429 only
});

Set maxRetries: 0 to opt out (rarely useful — you'll want at least one retry to absorb transient blips).

What never to retry

  • Validation failures (bad_request) — fix and resend, not the same thing as a retry.
  • Permission errors (forbidden) — fix the key.
  • Idempotency conflicts (conflict) — use a fresh key.
  • 404s on GET — the resource isn't there; retrying is a no-op.
  • 400 mode=signed rejections on user-owned routes — switch to mode=unsigned; retrying with signed will fail every time.

If you're tempted to retry one of these, treat it as a bug in your client and fix it upstream.