Skip to content

Retry

@modularityjs/retry is the retry-policy primitive: a RetryPolicy shape plus the computeDelay backoff math (fixed or exponential, capped, optionally jittered). Zero deps, no DI, no Module — like exception and factory, it sits on the infrastructure allowlist and is importable from anywhere without module wiring. The @Retryable method decorator ships separately in @modularityjs/retry-plugins.

The policy

typescript
import { computeDelay, type RetryPolicy } from '@modularityjs/retry';

const policy: RetryPolicy = {
  maxAttempts: 5,
  strategy: 'exponential', // or 'fixed'
  delayMs: 100,
  maxDelayMs: 5_000, // optional clamp
  jitterRatio: 0.2, // optional, 0..1 — ±20% spread
};
FieldMeaning
maxAttemptsTotal attempts, including the first.
strategy'fixed' — every delay is delayMs. 'exponential'delayMs * 2^(attempt - 1).
delayMsBase delay in milliseconds.
maxDelayMsOptional upper clamp applied before jitter.
jitterRatioRandom spread around the computed delay (0.2 = ±20%), clamped to ≥ 0. Use it to avoid thundering-herd retries.

computeDelay

Pure function from (policy, attempt) to a delay in milliseconds — attempt is 1-based (the delay after the first failure is computeDelay(policy, 1)):

typescript
import { computeDelay } from '@modularityjs/retry';

const policy = {
  maxAttempts: 4,
  strategy: 'exponential' as const,
  delayMs: 100,
};

computeDelay(policy, 1); // 100
computeDelay(policy, 2); // 200
computeDelay(policy, 3); // 400

The primitive deliberately contains no loop and no sleep — callers own the control flow (a for loop, a queue redelivery, a scheduler re-arm) and just ask for the next delay. That is what makes it reusable across the framework: webhook delivery retries (webhook-direct), queue redelivery (queue-memory, queue-redis), and the @Retryable decorator all share this one piece of backoff math instead of four hand-rolled Math.pow(2, n) copies.

A hand-rolled loop looks like this:

typescript
import { computeDelay, type RetryPolicy } from '@modularityjs/retry';

async function withRetry<T>(
  policy: RetryPolicy,
  fn: () => Promise<T>,
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (attempt >= policy.maxAttempts) {
        break;
      }
      await new Promise((r) => setTimeout(r, computeDelay(policy, attempt)));
    }
  }
  throw lastError;
}

@Retryable (@modularityjs/retry-plugins)

For DI-managed services, @modularityjs/retry-plugins packages that loop as a method decorator riding the shared plugins interceptor seam:

typescript
import { Injectable } from '@modularityjs/di';
import { Retryable } from '@modularityjs/retry-plugins';

@Injectable()
export class PaymentGateway {
  @Retryable({ policy: { maxAttempts: 5, delayMs: 250 } })
  async charge(orderId: string): Promise<Receipt> {
    // transient network failures retry with exponential backoff
  }
}

Wire the module (it imports PluginsModule itself):

typescript
import { RetryPluginsModule } from '@modularityjs/retry-plugins';

const modules = [/* ... */ RetryPluginsModule];

A decorated method whose module isn't wired fails boot loudly — the decorator never silently degrades to "no retries".

Options

typescript
interface RetryableOptions {
  /** Merged over `{ maxAttempts: 3, strategy: 'exponential', delayMs: 100, maxDelayMs: 5000 }`. */
  policy?: Partial<RetryPolicy>;
  /** Which errors retry. Default: everything except CircuitOpenException. */
  retryOn?: (error: unknown) => boolean;
  /** Interceptor chain position; lower = outermost. Default 200. */
  order?: number;
}
  • retryOn filters which errors are worth another attempt — validation failures and 4xx-mapped exceptions usually aren't:

    typescript
    @Retryable({ retryOn: (error) => error instanceof TimeoutException })
  • The default retryOn already excludes CircuitOpenException — never back off past an open circuit (see below).

  • policy.maxAttempts must be an integer ≥ 1; anything else is rejected with a ValidationException at call time rather than looping zero times.

Composing with a circuit breaker

@Retryable (order 200) wraps outside @WithCircuitBreaker (order 300), so the breaker sits inside the retry loop — every attempt is individually admitted and recorded, and once the circuit opens the default retryOn stops retrying immediately:

typescript
import { Retryable } from '@modularityjs/retry-plugins';
import { WithCircuitBreaker } from '@modularityjs/circuit-breaker-plugins';

@Injectable()
export class SearchClient {
  @Retryable({ policy: { maxAttempts: 3 } })
  @WithCircuitBreaker({ policy: { failureThreshold: 5 } })
  async query(term: string): Promise<Results> {
    // ...
  }
}

See Circuit Breaker for the breaker semantics and Plugins for the shared decorator seam (@Cacheable 100 → @CacheEvict 150 → @Retryable 200 → @WithCircuitBreaker 300 → @Transactional 400 — lower is outermost).

When to reach for it

Before writing Math.pow(2, attempt - 1) anywhere — a poller, a webhook sender, a queue consumer — depend on this primitive instead. If the operation you need doesn't exist, extend the primitive rather than reimplementing it in the consumer.

Next Steps

  • Circuit Breaker — stop calling a dependency that keeps failing
  • Plugins — the method-interceptor seam @Retryable rides
  • Queue — redelivery with its own retry policy