What are Retries?
Retries are a fundamental resilience pattern that automatically re-executes failed operations. When a function fails due to transient errors (like network glitches or temporary service unavailability), retries give it another chance to succeed before propagating the error to your application.
Why Use Retries?
Transient failures are common in distributed systems:
Network instability : Temporary packet loss or connection issues
Service overload : Momentary capacity constraints that resolve quickly
Rate limiting : Brief throttling that clears after a short wait
Resource contention : Temporary locks or conflicts that resolve
Retries help your application gracefully handle these temporary issues without manual intervention.
Retries should only be used for idempotent operations that can be safely executed multiple times. Avoid retrying operations that modify state in non-idempotent ways (e.g., creating records without idempotency keys).
How Retries Work in Resilience
The withResilience wrapper manages the retry loop automatically. Here’s how it works:
Execute the wrapped function
If it succeeds, return the result immediately
If it fails, check if retries remain and the error is retryable
If retrying, wait for the backoff delay (if configured)
Attempt the function again
Repeat until success or retry limit reached
From src/index.ts:129-163:
for ( let attempt = 1 ; attempt <= retries + 1 ; attempt ++ ) {
hooks ?. onAttempt ?.({ name , attempt });
if ( breaker && ! breaker . canAttempt ()) {
const e = new Error ( `CircuitOpenError: ${ name } ` );
lastErr = e ;
throw e ;
}
const start = Date . now ();
try {
const controller = config . useAbortSignal ? new AbortController () : undefined ;
const result = await runWithActiveSignal ( controller ?. signal , async () => {
const execPromise = Promise . resolve ( fn ( ... args )) as Promise < Awaited < ReturnType < Fn >>>;
return await withTimeout ( execPromise , config . timeoutMs , controller );
});
const timeMs = Date . now () - start ;
breaker ?. onSuccess ();
hooks ?. onSuccess ?.({ name , attempt , timeMs });
return result ;
} catch ( err ) {
const timeMs = Date . now () - start ;
lastErr = err ;
breaker ?. onFailure ();
hooks ?. onFailure ?.({ name , attempt , timeMs , error: err });
const shouldRetry = attempt <= retries && retryOn ( err );
if ( ! shouldRetry ) throw err ;
const waitMs = computeBackoffMs ( config . backoff , attempt );
hooks ?. onRetry ?.({ name , attempt , delayMs: waitMs , error: err });
if ( waitMs > 0 ) await delay ( waitMs );
}
}
Configuration Options
retries
The maximum number of retry attempts after the initial execution.
const resilient = withResilience ( fetchData , {
retries: 3 // Will attempt up to 4 times total (1 initial + 3 retries)
});
The total number of attempts is retries + 1. Setting retries: 3 means 4 total attempts.
retryOn
A predicate function that determines whether a specific error should trigger a retry. This gives you fine-grained control over which failures are retryable.
const resilient = withResilience ( apiCall , {
retries: 3 ,
retryOn : ( err ) => {
// Only retry on network errors, not on validation errors
if ( err instanceof ValidationError ) return false ;
if ( err instanceof NetworkError ) return true ;
return false ;
}
});
Default behavior : If retryOn is not specified, all errors are retryable (from src/index.ts:119):
const retryOn = config . retryOn ?? (() => true );
Selective Retry Examples
HTTP Status Codes
Timeout Only
Custom Error Types
const resilient = withResilience ( fetchAPI , {
retries: 3 ,
retryOn : ( err ) => {
// Retry on 5xx server errors, but not 4xx client errors
if ( err . response ?. status >= 500 ) return true ;
return false ;
}
});
Complete Example
import { withResilience } from '@oldwhisper/resilience' ;
async function fetchUserData ( userId : string ) {
const response = await fetch ( `https://api.example.com/users/ ${ userId } ` );
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
return response . json ();
}
const resilientFetch = withResilience ( fetchUserData , {
name: 'fetchUserData' ,
retries: 3 ,
backoff: { type: 'exponential' , baseDelayMs: 100 , maxDelayMs: 2000 , jitter: true },
retryOn : ( err ) => {
// Retry server errors and timeouts, but not client errors
const msg = err . message ;
return msg . includes ( 'HTTP 5' ) || msg === 'TimeoutError' ;
},
hooks: {
onRetry : ({ attempt , delayMs , error }) => {
console . log ( `Retry attempt ${ attempt } after ${ delayMs } ms:` , error );
}
}
});
try {
const userData = await resilientFetch ( 'user-123' );
console . log ( 'Success:' , userData );
} catch ( error ) {
console . error ( 'All retries exhausted:' , error );
}
Hooks for Monitoring
Retries trigger specific hooks that let you observe the retry behavior:
onAttempt : Called before each attempt (including the initial one)
onRetry : Called when a retry is about to happen, includes the delay
onFailure : Called after each failed attempt
onSuccess : Called when an attempt succeeds
const resilient = withResilience ( task , {
retries: 3 ,
hooks: {
onAttempt : ({ name , attempt }) => {
console . log ( `[ ${ name } ] Attempt ${ attempt } ` );
},
onRetry : ({ attempt , delayMs , error }) => {
console . log ( `Retrying after ${ delayMs } ms due to:` , error );
},
onSuccess : ({ attempt , timeMs }) => {
console . log ( `Success on attempt ${ attempt } ( ${ timeMs } ms)` );
},
onFailure : ({ attempt , error }) => {
console . log ( `Attempt ${ attempt } failed:` , error );
}
}
});
Best Practices
Combine with Backoff : Always use retries with a backoff strategy to avoid overwhelming the failing service.
Set reasonable limits : Don’t retry indefinitely. 3-5 retries is usually sufficient.
Use retryOn wisely : Only retry transient errors, not validation or authentication failures.
Monitor retry patterns : Use hooks to track retry rates and identify systemic issues.
Consider timeouts : Combine with timeouts to prevent indefinite waits.
Respect idempotency : Only retry operations that are safe to execute multiple times.