Retries with exponential backoff
Use case
Automatically retry failed requests — network errors, 5xx responses, 429 rate-limit with Retry-After — with increasing delays between attempts and jitter to avoid thundering herds.
Recommended: @parcely/retry
parcely ships a first-party retry addon. Install it, call install(client), and you're done.
npm install @parcely/retry
import { createClient } from '@parcely/core'
import { createRetry } from '@parcely/retry'
const http = createClient({ baseURL: 'https://api.example.com' })
const retry = createRetry({
count: 3, // default: 3 retries
baseDelayMs: 300, // full-jitter exp backoff: 0–300, 0–600, 0–1200 …
maxDelayMs: 30_000, // cap per-attempt delay
// methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'], // default — idempotent only
retryAfter: true, // honor server's Retry-After on 429 / 503
onRetry: ({ attempt, error, delayMs }) => {
console.warn(`retry ${attempt} in ${delayMs}ms — ${error.code}`)
},
})
retry.install(http)
That's the full integration. The defaults match what most teams want: idempotent methods only, full-jitter exponential backoff, Retry-After honored, skip on ERR_ABORTED and ERR_VALIDATION. See the @parcely/retry reference for every option.
Customising the retry predicate
createRetry({
retryOn: (err) => {
// Only retry on 503 and 504 — not 500 or 502.
return err.code === 'ERR_HTTP_STATUS' && [503, 504].includes(err.status ?? 0)
},
})
Opting in a non-idempotent method
POST and PATCH are NOT retried by default because replaying a side-effectful request can cause duplicates. If your POST is idempotent (e.g. has an Idempotency-Key header), opt in explicitly:
createRetry({
methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'POST'],
})
Alternative: hand-rolled interceptor
If you want zero additional dependencies, the same behaviour is ~15 lines of interceptor:
import { createClient, isHttpError } from '@parcely/core'
const http = createClient({ baseURL: 'https://api.example.com' })
const MAX_RETRIES = 3
const BASE_DELAY = 300
http.interceptors.response.use(undefined, async (err) => {
if (!isHttpError(err)) throw err
const retryCount = (err.config as { _retryCount?: number })._retryCount ?? 0
const isRetryable =
err.code === 'ERR_NETWORK' ||
err.code === 'ERR_TIMEOUT' ||
(err.status !== undefined && err.status >= 500)
if (!isRetryable || retryCount >= MAX_RETRIES) throw err
// Full-jitter exponential backoff.
const cap = BASE_DELAY * Math.pow(2, retryCount)
const delay = Math.floor(Math.random() * cap)
await new Promise((resolve) => setTimeout(resolve, delay))
const retryConfig = { ...err.config, _retryCount: retryCount + 1 }
return http.request(retryConfig)
})
@parcely/retry adds on top of this: AbortSignal-aware backoff sleep (cancels cleanly if the user aborts mid-retry), Retry-After parsing (integer + HTTP-date, clamped), an onRetry hook, idempotent-method filtering by default, and coexistence with @parcely/auth-token's refresh-on-401.
Axios comparison
- axios
- parcely
import axios from 'axios'
import axiosRetry from 'axios-retry'
const http = axios.create({ baseURL: 'https://api.example.com' })
axiosRetry(http, { retries: 3, retryDelay: axiosRetry.exponentialDelay })
import { createClient } from '@parcely/core'
import { createRetry } from '@parcely/retry'
const http = createClient({ baseURL: 'https://api.example.com' })
createRetry({ count: 3 }).install(http)
Notes and gotchas
- Non-idempotent methods (POST, PATCH) aren't retried by default. Replaying a side-effectful request without an idempotency key can cause duplicate charges, double-emails, etc.
Retry-Afteris clamped atmaxDelayMsso a hostile or buggy server (Retry-After: 999999) can't stall your client.- The built-in
timeoutapplies per attempt. Each retry starts a fresh timeout. @parcely/retrysets_retryCounton the retry config; combined with@parcely/auth-token's_retrymarker, retry attempts and auth refreshes don't double-count each other.- Pair with the authentication refresh guide if you need both retry and token refresh.