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
| Code | Retry? | Why |
|---|---|---|
429 rate_limited | Yes | Quota will reset on the next minute / day boundary |
500 internal_error | Yes | Usually a transient upstream (Supabase, Helius, Privy) hiccup |
400 bad_request | No | The payload is wrong; retrying changes nothing |
401 unauthorized | No | Add the header and resend, but it's not a "retry" |
403 forbidden | No | Key or scope problem — fix the key, then resend |
404 not_found | No | The resource doesn't exist; retrying won't conjure it |
409 conflict | No | Idempotency-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
Idempotency-KeyNetwork 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
2xxresponse 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=signedrejections on user-owned routes — switch tomode=unsigned; retrying withsignedwill fail every time.
If you're tempted to retry one of these, treat it as a bug in your client and fix it upstream.