$ cat ~/field-notes/retry-failed-requests.md
Retrying Failed Requests: Exponential Backoff Explained
Our payment webhook failed. Stripe sent the payment confirmation, our API returned 500, Stripe never retried. Customer charged but order never fulfilled. Manual reconciliation for 50 orders.
I added retry logic with exponential backoff. Transient failures now retry automatically. Haven't had a lost webhook since. Five lines of code prevented a recurring nightmare.
Here's how to retry failed requests properly, with exponential backoff, jitter, and real production patterns.
Why Requests Fail
Network requests fail all the time:
// This will fail eventually
const response = await fetch('https://api.example.com/data');
Common failures:
- Network timeout (WiFi drops, slow connection)
- Server temporarily down (restart, deploy)
- Rate limiting (429 Too Many Requests)
- Transient errors (500 Internal Server Error)
- Database connection issues (temporary)
One-time requests fail permanently. Retrying fixes most failures.
The Simplest Retry
async function fetchWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
// Non-ok response, throw to retry
throw new Error(`HTTP ${response.status}`);
} catch (error) {
// Last attempt, throw error
if (i === maxRetries - 1) {
throw error;
}
console.log(`Attempt ${i + 1} failed, retrying...`);
}
}
}
// Usage
try {
const data = await fetchWithRetry('https://api.example.com/data', 3);
console.log(data);
} catch (error) {
console.error('All retries failed:', error);
}
Problem: Retries immediately. Hammers failing server.
Adding Delays
Wait between retries:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, maxRetries = 3, delayMs = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === maxRetries - 1) {
throw error;
}
console.log(`Attempt ${i + 1} failed, waiting ${delayMs}ms...`);
await sleep(delayMs);
}
}
}
// Try 3 times with 1 second between attempts
const data = await fetchWithRetry('https://api.example.com/data', 3, 1000);
Problem: Fixed delay. All clients retry at same time (thundering herd).
Exponential Backoff
Double the delay after each retry:
async function fetchWithRetry(url, maxRetries = 5) {
let delay = 1000; // Start with 1 second
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === maxRetries - 1) {
throw error;
}
console.log(`Attempt ${i + 1} failed, waiting ${delay}ms...`);
await sleep(delay);
delay *= 2; // Double the delay: 1s, 2s, 4s, 8s, 16s
}
}
}
// Retry with exponential backoff:
// Attempt 1: fail → wait 1s
// Attempt 2: fail → wait 2s
// Attempt 3: fail → wait 4s
// Attempt 4: fail → wait 8s
// Attempt 5: fail → throw error
Delays:
Attempt 1: 0s (immediate)
Attempt 2: 1s delay
Attempt 3: 2s delay
Attempt 4: 4s delay
Attempt 5: 8s delay
Total time: 15 seconds max
Adding Jitter
Randomize delays to prevent thundering herd:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, maxRetries = 5) {
let delay = 1000;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === maxRetries - 1) {
throw error;
}
// Add jitter: random delay between 0 and calculated delay
const jitter = Math.random() * delay;
console.log(`Attempt ${i + 1} failed, waiting ${Math.round(jitter)}ms...`);
await sleep(jitter);
delay *= 2;
}
}
}
// With jitter:
// Attempt 2: 0-1000ms (random)
// Attempt 3: 0-2000ms (random)
// Attempt 4: 0-4000ms (random)
// Attempt 5: 0-8000ms (random)
Why jitter matters:
Without jitter:
Server crashes at 12:00:00
1000 clients all retry at 12:00:01 → server crashes again
1000 clients all retry at 12:00:03 → server crashes again
With jitter:
Server crashes at 12:00:00
1000 clients retry randomly between 12:00:01-12:00:02
Server recovers gradually
Full Implementation
class RetryableError extends Error {
constructor(message, retryable = true) {
super(message);
this.retryable = retryable;
}
}
async function fetchWithRetry(url, options = {}) {
const {
maxRetries = 5,
initialDelay = 1000,
maxDelay = 32000,
factor = 2,
onRetry = null
} = options;
let delay = initialDelay;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
// Success
if (response.ok) {
return await response.json();
}
// Client errors (4xx) - don't retry
if (response.status >= 400 && response.status < 500) {
if (response.status === 429) {
// Rate limited - retry with Retry-After header
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
delay = parseInt(retryAfter) * 1000;
}
throw new RetryableError(`Rate limited: ${response.status}`);
}
// Other 4xx - don't retry
throw new RetryableError(
`Client error: ${response.status}`,
false
);
}
// Server errors (5xx) - retry
throw new RetryableError(`Server error: ${response.status}`);
} catch (error) {
const isLastAttempt = attempt === maxRetries - 1;
// Don't retry if error is not retryable
if (error instanceof RetryableError && !error.retryable) {
throw error;
}
// Last attempt - throw error
if (isLastAttempt) {
throw error;
}
// Calculate delay with jitter
const jitter = Math.random() * delay;
const actualDelay = Math.min(jitter, maxDelay);
// Call retry callback if provided
if (onRetry) {
onRetry(attempt + 1, actualDelay, error);
}
console.log(
`Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}. ` +
`Retrying in ${Math.round(actualDelay)}ms...`
);
await sleep(actualDelay);
delay = Math.min(delay * factor, maxDelay);
}
}
}
// Usage
try {
const data = await fetchWithRetry('https://api.example.com/data', {
maxRetries: 5,
initialDelay: 1000,
maxDelay: 32000,
onRetry: (attempt, delay, error) => {
console.log(`Retry attempt ${attempt} after ${delay}ms: ${error.message}`);
}
});
console.log('Success:', data);
} catch (error) {
console.error('Failed after retries:', error);
}
Retry Only Specific Errors
function isRetryable(error, response) {
// Network errors - always retry
if (error instanceof TypeError && error.message.includes('fetch')) {
return true;
}
// No response - retry
if (!response) {
return true;
}
// Rate limiting - retry
if (response.status === 429) {
return true;
}
// Server errors - retry
if (response.status >= 500) {
return true;
}
// Service unavailable - retry
if (response.status === 503) {
return true;
}
// Gateway errors - retry
if (response.status === 502 || response.status === 504) {
return true;
}
// Client errors - don't retry
if (response.status >= 400 && response.status < 500) {
return false;
}
return false;
}
async function fetchWithRetry(url, maxRetries = 5) {
let delay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
// Check if retryable
if (!isRetryable(null, response)) {
throw new Error(`Non-retryable error: ${response.status}`);
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
// Network error - check if retryable
if (!isRetryable(error, null)) {
throw error;
}
if (attempt === maxRetries - 1) {
throw error;
}
const jitter = Math.random() * delay;
await sleep(jitter);
delay *= 2;
}
}
}
Respecting Retry-After Header
async function fetchWithRetry(url, maxRetries = 5) {
let delay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
// Rate limited - respect Retry-After header
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
// Retry-After can be seconds or HTTP date
const retryDelay = isNaN(retryAfter)
? new Date(retryAfter).getTime() - Date.now()
: parseInt(retryAfter) * 1000;
console.log(`Rate limited, waiting ${retryDelay}ms as requested`);
await sleep(retryDelay);
continue;
}
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
const jitter = Math.random() * delay;
await sleep(jitter);
delay *= 2;
}
}
}
Axios with Retry
const axios = require('axios');
const axiosRetry = require('axios-retry');
// Configure axios to retry
axiosRetry(axios, {
retries: 5,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) => {
// Retry on network errors or 5xx responses
return axiosRetry.isNetworkOrIdempotentRequestError(error)
|| error.response?.status >= 500;
},
onRetry: (retryCount, error, requestConfig) => {
console.log(`Retry attempt ${retryCount} for ${requestConfig.url}`);
}
});
// Use axios normally - retries automatically
try {
const response = await axios.get('https://api.example.com/data');
console.log(response.data);
} catch (error) {
console.error('Failed after retries:', error);
}
Promise-Retry Library
const promiseRetry = require('promise-retry');
const data = await promiseRetry(async (retry, number) => {
console.log(`Attempt ${number}`);
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
// Retry on failure
retry(error);
}
}, {
retries: 5,
factor: 2,
minTimeout: 1000,
maxTimeout: 32000,
randomize: true // Add jitter
});
Webhook Retry Pattern
class WebhookRetryQueue {
constructor() {
this.queue = [];
this.processing = false;
}
async send(url, payload) {
this.queue.push({ url, payload, attempts: 0 });
if (!this.processing) {
this.processQueue();
}
}
async processQueue() {
this.processing = true;
while (this.queue.length > 0) {
const webhook = this.queue[0];
try {
await this.sendWithRetry(webhook.url, webhook.payload);
this.queue.shift(); // Success - remove from queue
} catch (error) {
webhook.attempts++;
if (webhook.attempts >= 5) {
console.error('Webhook failed after 5 attempts:', webhook.url);
this.queue.shift(); // Give up
} else {
// Move to back of queue
this.queue.shift();
this.queue.push(webhook);
// Wait before processing next
await sleep(Math.pow(2, webhook.attempts) * 1000);
}
}
}
this.processing = false;
}
async sendWithRetry(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
}
// Usage
const webhookQueue = new WebhookRetryQueue();
app.post('/order', async (req, res) => {
const order = await createOrder(req.body);
// Send webhook - will retry on failure
webhookQueue.send('https://partner.com/webhook', {
event: 'order.created',
order: order
});
res.json(order);
});
Circuit Breaker Pattern
Stop retrying if service is down:
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
console.log(`Circuit breaker OPEN for ${this.timeout}ms`);
}
}
}
// Usage
const breaker = new CircuitBreaker(5, 60000); // Open after 5 failures, retry after 60s
async function callAPI() {
return await breaker.execute(async () => {
return await fetchWithRetry('https://api.example.com/data');
});
}
Real-World Example
class APIClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
async request(endpoint, options = {}) {
const {
method = 'GET',
body = null,
maxRetries = 5
} = options;
let delay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getToken()}`
},
body: body ? JSON.stringify(body) : null
});
// Success
if (response.ok) {
return await response.json();
}
// Rate limited
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
delay = retryAfter ? parseInt(retryAfter) * 1000 : delay;
throw new Error('Rate limited');
}
// Client error - don't retry
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
// Server error - retry
throw new Error(`Server error: ${response.status}`);
} catch (error) {
// Don't retry client errors
if (error.message.includes('Client error')) {
throw error;
}
const isLastAttempt = attempt === maxRetries - 1;
if (isLastAttempt) {
throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
}
const jitter = Math.random() * delay;
console.log(`Attempt ${attempt + 1} failed, retrying in ${Math.round(jitter)}ms...`);
await sleep(jitter);
delay = Math.min(delay * 2, 32000);
}
}
}
getToken() {
return 'your-api-token';
}
}
// Usage
const api = new APIClient('https://api.example.com');
try {
const data = await api.request('/users', { maxRetries: 3 });
console.log(data);
} catch (error) {
console.error('API request failed:', error);
}
Testing Retry Logic
describe('fetchWithRetry', () => {
it('should succeed on first attempt', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'success' })
});
global.fetch = mockFetch;
const result = await fetchWithRetry('https://api.example.com/data');
expect(result).toEqual({ data: 'success' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should retry on failure and eventually succeed', async () => {
const mockFetch = jest.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'success' })
});
global.fetch = mockFetch;
const result = await fetchWithRetry('https://api.example.com/data', 3);
expect(result).toEqual({ data: 'success' });
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should throw after max retries', async () => {
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
global.fetch = mockFetch;
await expect(
fetchWithRetry('https://api.example.com/data', 3)
).rejects.toThrow('Network error');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
Common Mistakes
Mistake 1: Retrying Non-Idempotent Operations
// BAD - Retrying POST can create duplicates
async function createUser(userData) {
return await fetchWithRetry('/users', {
method: 'POST',
body: userData
});
}
// Request fails after creating user → retry creates duplicate!
// GOOD - Use idempotency key
async function createUser(userData) {
return await fetch('/users', {
method: 'POST',
headers: {
'Idempotency-Key': generateUniqueKey()
},
body: userData
});
}
Mistake 2: No Maximum Delay
// BAD - Delay grows forever
let delay = 1000;
delay *= 2; // 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s...
// GOOD - Cap maximum delay
const maxDelay = 32000;
delay = Math.min(delay * 2, maxDelay);
Mistake 3: Not Logging Retries
// BAD - Silent retries
await fetchWithRetry(url);
// GOOD - Log retries for debugging
await fetchWithRetry(url, {
onRetry: (attempt, delay, error) => {
console.log(`Retry ${attempt} after ${delay}ms: ${error.message}`);
}
});
The Bottom Line
Network requests fail. Retrying with exponential backoff handles transient failures gracefully.
Start with 1 second, double each retry - 1s, 2s, 4s, 8s prevents hammering.
Add jitter - randomize delays to prevent thundering herd problems.
Respect Retry-After - honor rate limit headers from servers.
Don't retry forever - max 5 retries is usually enough.
We lost payment webhooks because we didn't retry. Added exponential backoff, haven't lost one since. Five lines of code fixed a recurring problem.
Add retry logic to critical requests today. Start simple with exponential backoff. Your users won't notice failures anymore.