No description
Find a file
Lilith da72804a75
Some checks failed
Build and Publish / build-and-publish (push) Failing after 54s
deps-upgrade(root): ⬆️ Update dependencies to latest stable versions for security fixes and compatibility
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-08 19:26:49 -07:00
.forgejo/workflows fix(ci): fix backslash-bang syntax error in workflow 2026-01-30 15:49:46 -08:00
src chore: initial commit with publish config 2026-01-21 12:30:20 -08:00
.gitignore chore: initial commit with publish config 2026-01-21 12:30:20 -08:00
package.json deps-upgrade(root): ⬆️ Update dependencies to latest stable versions for security fixes and compatibility 2026-03-08 19:26:49 -07:00
README.md chore: trigger CI publish 2026-01-30 11:56:21 -08:00
tsconfig.json chore: initial commit with publish config 2026-01-21 12:30:20 -08:00
tsup.config.ts chore(build): 🔧 Update tsup config to optimize bundle size via enhanced compilation settings 2026-01-21 15:33:43 -08:00

@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 retry
  • options?: 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 retry
  • options?: 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

  1. 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
  2. Set reasonable max delay: Prevent exponential backoff from growing too large

  3. Enable jitter: Always use jitter in production to prevent thundering herd

  4. Use error filtering: Don't retry validation errors or client errors (4xx)

  5. Add logging: Use onRetry or @RetryWithLogging for observability

  6. 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.

  • @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