Retries and backoff
Updated 6 September 20263 min read
On this page
Requests fail for reasons that have nothing to do with your code: a dropped connection, a busy upstream, a deploy in progress. A good client retries the right failures, at the right pace, safely. This page lists what to retry and how.
What to retry
| Outcome | Retry? | Notes |
|---|---|---|
| Network error, connection reset, DNS failure | Yes | The request may or may not have reached us. Use an idempotency key. |
| Timeout on your side | Yes | The operation may have completed. |
408 Request Timeout |
Yes | |
429 Too Many Requests |
Yes | Wait for the Retry-After interval. |
500 Internal Server Error |
Yes | |
502 Bad Gateway |
Yes | |
503 Service Unavailable |
Yes | |
504 Gateway Timeout |
Yes | |
Any other 4xx |
No | The request is wrong. Retrying cannot fix it. |
A 409 idempotency_conflict is the one 4xx worth retrying: it means the first attempt is still running. Retry it with the same key after a short wait.
Important
Only retry POST requests when you send an Idempotency-Key. Without one, a retry after a timeout can create a duplicate payout.
The backoff schedule
Use exponential backoff with full jitter. Wait a random time between zero and an exponentially growing ceiling.
| Setting | Recommended |
|---|---|
| Base delay | 500 ms |
| Multiplier | 2 |
| Jitter | Full: pick uniformly from 0 to the current ceiling |
| Maximum delay | 30 seconds |
| Maximum attempts | 5 |
That gives ceilings of 0.5, 1, 2, 4 and 8 seconds for the first five retries, never above 30 seconds. Jitter matters more than it looks: without it, every client that failed together retries together and hits the API as a synchronized wave.
async function withRetries<T>(fn: () => Promise<Response>, parse: (r: Response) => Promise<T>): Promise<T> {
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
for (let attempt = 0; ; attempt++) {
try {
const res = await fn();
if (res.ok) return parse(res);
if (!RETRYABLE.has(res.status) || attempt >= 4) throw new Error(`HTTP ${res.status}`);
const retryAfter = Number(res.headers.get("retry-after"));
const ceiling = Math.min(30_000, 500 * 2 ** attempt);
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.random() * ceiling);
} catch (error) {
if (attempt >= 4 || !isNetworkError(error)) throw error;
await sleep(Math.random() * Math.min(30_000, 500 * 2 ** attempt));
}
}
}
Honor Retry-After
A 429 response includes Retry-After, the number of seconds until you should try again. Use it instead of your own schedule. The same header can appear on 503 during maintenance. See Rate limits.
Timeouts
The API server gives each request up to 60 seconds. Set your client timeout lower than that if you can tolerate it, and treat a timeout as an unknown outcome rather than a failure. The SDKs default to 30 seconds. If a POST times out, retry with the same idempotency key. The server returns the stored result if the first attempt finished.
SDK defaults
Both SDKs retry automatically with the policy above, using max_retries of 2 by default and a 30-second timeout. Raise or lower it when you create the client.
const ll = new Ledgerline(process.env.LEDGERLINE_API_KEY!, { maxRetries: 4, timeout: 20_000 });
client = ledgerline.Client(api_key=os.environ["LEDGERLINE_API_KEY"], max_retries=4, timeout=20)
If you wrap an SDK call in your own retry loop, keep the idempotency key constant across your attempts, and do not multiply retries so that one failure turns into dozens of requests.
Circuit breakers
When Ledgerline is unavailable, retries add load without helping. Add a circuit breaker that stops sending after consecutive failures, and check Status and incidents before you escalate. For webhook deliveries from Ledgerline to you, the retry policy is separate: see Webhook delivery and retries.
Checklist
- Send an
Idempotency-Keyon everyPOST. - Retry only the status codes in the table.
- Back off with jitter, capped at 30 seconds, for at most 5 attempts.
- Honor
Retry-After. - Log the
Ledgerline-Request-Idof every failed attempt.