|
Some checks failed
Build and Publish / build-and-publish (push) Failing after 54s
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
@lilith/retry
Production-ready retry utility with exponential backoff, jitter, and TypeScript decorators.
Features
- Multiple backoff strategies: Exponential, linear, or constant delay
- Jitter support: Prevent thundering herd with randomized delays
- Type-safe: Full TypeScript support with strict typing
- Decorators: Method decorators for clean integration
- Flexible error handling: Conditional retry based on error types or predicates
- Detailed metadata: Track attempts, timing, and results
- Zero dependencies: Lightweight and self-contained
Installation
pnpm add @lilith/retry
Quick Start
Basic Retry
import { retry } from '@lilith/retry';
const data = await retry(
async () => await fetch('https://api.example.com').then(r => r.json()),
{
attempts: 5,
delay: 1000,
backoff: 'exponential',
}
);
Retry with Result Metadata
import { retryWithResult } from '@lilith/retry';
const result = await retryWithResult(
async () => await riskyOperation(),
{ attempts: 3, delay: 1000 }
);
if (result.success) {
console.log(`Success after ${result.attempts} attempts:`, result.value);
console.log(`Total time: ${result.totalTime}ms`);
} else {
console.error(`Failed after ${result.attempts} attempts:`, result.error);
}
Method Decorators
import { WithRetry, RetryOnError } from '@lilith/retry';
class ApiClient {
@WithRetry({ attempts: 5, delay: 1000 })
async fetchData(id: string): Promise<Data> {
return await fetch(`/api/data/${id}`).then(r => r.json());
}
@RetryOnError([NetworkError, TimeoutError], { attempts: 3 })
async query(sql: string): Promise<Result[]> {
return await this.db.execute(sql);
}
}
API Reference
Core Functions
retry<T>(fn, options?): Promise<T>
Retry a function with configurable backoff strategy.
Parameters:
fn: () => Promise<T>- Function to retryoptions?: RetryOptions- Retry configuration
Returns: Promise resolving to function result
Throws: Error from final failed attempt
Example:
const result = await retry(
async () => await dangerousOperation(),
{
attempts: 5,
delay: 1000,
backoff: 'exponential',
maxDelay: 30000,
jitter: true,
retryOn: (error) => error instanceof NetworkError,
onRetry: (error, attempt) => console.log(`Retry ${attempt}:`, error.message),
}
);
retryWithResult<T>(fn, options?): Promise<RetryResult<T>>
Same as retry() but returns detailed result object instead of throwing.
Parameters:
fn: () => Promise<T>- Function to retryoptions?: RetryOptions- Retry configuration
Returns: Promise resolving to result with metadata
Example:
const result = await retryWithResult(
async () => await riskyOperation(),
{ attempts: 3 }
);
// result.success: boolean
// result.value?: T
// result.error?: Error
// result.attempts: number
// result.totalTime: number
calculateDelay(attempt, options): DelayCalculation
Calculate delay for a retry attempt (useful for testing or custom implementations).
Parameters:
attempt: number- Current attempt number (0-indexed)options: RetryOptions- Retry configuration
Returns: Delay calculation metadata
Example:
const delay = calculateDelay(2, {
attempts: 5,
delay: 1000,
backoff: 'exponential',
maxDelay: 30000,
jitter: true,
});
// delay.baseDelay: 4000 (1000 * 2^2)
// delay.finalDelay: 3421 (after jitter)
// delay.jitterApplied: true
Decorators
@WithRetry(options?)
Add retry behavior to async methods.
Example:
class Service {
@WithRetry({ attempts: 5, delay: 1000 })
async fetchData(id: string): Promise<Data> {
return await this.api.get(`/data/${id}`);
}
}
@RetryOnError(errorTypes, options?)
Retry only on specific error types.
Example:
class DatabaseClient {
@RetryOnError([NetworkError, TimeoutError], { attempts: 3 })
async query(sql: string): Promise<Result[]> {
return await this.db.execute(sql);
}
}
@RetryIf(predicate, options?)
Retry based on custom error predicate.
Example:
class ApiClient {
@RetryIf(
(error) => error.message.includes('rate limit'),
{ attempts: 5, delay: 5000 }
)
async makeRequest(): Promise<Response> {
return await fetch('/api/endpoint');
}
}
@RetryWithLogging(logger, options?)
Retry with automatic logging of retry attempts.
Example:
import { Logger } from '@nestjs/common';
class Service {
private readonly logger = new Logger(Service.name);
@RetryWithLogging(this.logger, { attempts: 3 })
async performOperation(): Promise<void> {
await this.riskyOperation();
}
}
Configuration
RetryOptions
interface RetryOptions {
/**
* Maximum number of retry attempts
* @default 3
*/
attempts: number;
/**
* Base delay in milliseconds between retries
* @default 1000
*/
delay: number;
/**
* Backoff strategy to use for calculating delays
* @default 'exponential'
*/
backoff: 'exponential' | 'linear' | 'constant';
/**
* Maximum delay in milliseconds (caps exponential backoff)
* @default 30000
*/
maxDelay?: number;
/**
* Add random jitter to delays to prevent thundering herd
* @default true
*/
jitter: boolean;
/**
* Predicate to determine if error should trigger retry
* If not provided, all errors trigger retry
*/
retryOn?: (error: Error) => boolean;
/**
* Callback invoked before each retry attempt
*/
onRetry?: (error: Error, attempt: number) => void | Promise<void>;
}
Backoff Strategies
Exponential Backoff
delay = min(baseDelay * 2^attempt, maxDelay)
Attempt 0: 1000ms
Attempt 1: 2000ms
Attempt 2: 4000ms
Attempt 3: 8000ms
Linear Backoff
delay = min(baseDelay * (attempt + 1), maxDelay)
Attempt 0: 1000ms
Attempt 1: 2000ms
Attempt 2: 3000ms
Attempt 3: 4000ms
Constant Backoff
delay = baseDelay
Attempt 0: 1000ms
Attempt 1: 1000ms
Attempt 2: 1000ms
Attempt 3: 1000ms
Jitter
When enabled, jitter applies random factor between 50% and 100% of calculated delay:
finalDelay = baseDelay * (0.5 + random() * 0.5)
This prevents thundering herd when multiple clients retry simultaneously.
Advanced Examples
Conditional Retry Based on Error Type
import { retry } from '@lilith/retry';
class NetworkError extends Error {}
class ValidationError extends Error {}
const data = await retry(
async () => await fetchData(),
{
attempts: 5,
delay: 1000,
retryOn: (error) => {
// Retry on network errors, but not validation errors
return error instanceof NetworkError;
},
}
);
Retry with Progress Callback
import { retry } from '@lilith/retry';
const data = await retry(
async () => await longRunningOperation(),
{
attempts: 10,
delay: 2000,
backoff: 'exponential',
maxDelay: 60000,
onRetry: async (error, attempt) => {
console.log(`Retry attempt ${attempt} after error:`, error.message);
await notifyAdmin(`Operation failed, retrying (attempt ${attempt})`);
},
}
);
HTTP Client with Smart Retry
import { retry } from '@lilith/retry';
class HttpClient {
async get<T>(url: string): Promise<T> {
return await retry(
async () => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
},
{
attempts: 5,
delay: 1000,
backoff: 'exponential',
retryOn: (error) => {
// Retry on network errors and 5xx, but not 4xx
return error.message.includes('5') || error.message.includes('ECONNREFUSED');
},
}
);
}
}
Database Connection with Logging
import { WithRetry } from '@lilith/retry';
import { Logger } from '@nestjs/common';
class DatabaseService {
private readonly logger = new Logger(DatabaseService.name);
@WithRetry({
attempts: 10,
delay: 5000,
backoff: 'exponential',
onRetry: (error, attempt) => {
this.logger.warn(`Database connection attempt ${attempt} failed, retrying...`);
},
})
async connect(): Promise<void> {
await this.database.connect();
}
}
Custom Error Filtering
import { RetryIf } from '@lilith/retry';
class RateLimitedClient {
@RetryIf(
(error) => {
// Retry on 429 Too Many Requests or 503 Service Unavailable
if (error instanceof HttpError) {
return error.status === 429 || error.status === 503;
}
return false;
},
{
attempts: 10,
delay: 1000,
backoff: 'exponential',
maxDelay: 60000,
}
)
async makeRequest(endpoint: string): Promise<Response> {
return await fetch(endpoint);
}
}
TypeScript Configuration
For decorators to work, enable these options in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Best Practices
-
Choose appropriate backoff strategy:
- Exponential: Best for most cases, prevents overwhelming the service
- Linear: When you need predictable retry timing
- Constant: For fast operations with expected brief failures
-
Set reasonable max delay: Prevent exponential backoff from growing too large
-
Enable jitter: Always use jitter in production to prevent thundering herd
-
Use error filtering: Don't retry validation errors or client errors (4xx)
-
Add logging: Use
onRetryor@RetryWithLoggingfor observability -
Set appropriate attempt limits: Balance between resilience and failing fast
Performance Considerations
- Zero runtime overhead when not retrying
- Minimal memory footprint - no dependencies
- Type-safe - all errors caught at compile time
- Tree-shakeable - only import what you use
Testing
Run the manual test suite:
pnpm exec tsx src/test-manual.ts
License
MIT
Contributing
Part of the Lilith Platform monorepo. See main repository for contribution guidelines.
Related Packages
@lilith/queue- Job queues with automatic retry@lilith/service-nestjs-bootstrap- NestJS services with built-in retry@lilith/ml-provider-clients- LLM clients with automatic retry