Home/Docs/retry
RT

retry

v0.1.2

@backendkit-labs/retry

Enterprise retry without the boilerplate. Returns Result<T, RetryError>, never throws.

Overview#

retry() wraps any async task and returns Result<T, RetryError> — it never throws. Errors become explicit values: the type system forces you to handle both the success and failure paths at every call site.

The library is built in two layers. The standalone retry() function covers 90% of use cases in two lines. RetryEngine is the stateful core — use it when you need shared config, per-engine metrics, or duck-typed integrations with circuit breaker, bulkhead, and observability.

Backoff Strategies#

Three built-in strategies, all composable with JitterDecorator. Pass a shorthand config object or a strategy instance directly.

PropTypeDescription
fixed{ type: 'fixed', baseDelay }Same delay every retry. Predictable, good for queues.
linear{ type: 'linear', baseDelay, multiplier?, maxDelay? }Delay grows linearly. Gentler ramp than exponential.
exponential{ type: 'exponential', baseDelay, multiplier?, maxDelay?, jitter? }Delay doubles each attempt. Add jitter: 'full' | 'equal' | 'decorrelated' to prevent thundering herd.

Configuration#

PropTypeDescription
maxAttemptsnumberTotal attempts including the first. maxAttempts: 3 = 1 call + 2 retries.
backoffBackoffConfig | BackoffStrategyDelay strategy between retries.
retryIf(error) => booleanCustom predicate — return false to stop retrying. Default: retry on 5xx, network, timeout.
abortIf(error) => booleanReturn true to abort immediately without consuming remaining attempts. Default: abort on 4xx (except 429).
timeout.attemptTimeoutMsnumberCap per-call duration. Exceeded attempts fail with type: "timeout" and are retried.
timeout.globalTimeoutMsnumberCap the entire retry operation including delays. Abort when exceeded.
budget.windowMsnumberSliding window for retry ratio tracking (ms).
budget.maxRetryRationumberMax fraction of calls that may be retries. Prevents retry storms.
fallback(error) => TReturn a default value on exhaustion instead of err(...).
dynamicDelay(error, attempt) => numberOverride backoff for this attempt — useful for Retry-After headers.
hooksRetryHooksLifecycle hooks: beforeRetry, afterRetry, onRetrySuccess, onExhausted, onBudgetExhausted.
idempotencyPartial<IdempotencyConfig>Cache successful results and return them on duplicate calls. See Idempotency below.

NestJS Integration#

Import RetryModule once. It registers RetryService and an optional global RetryInterceptor. Use @Retry() on any service method — the interceptor handles the retry loop without changing the method signature.

app.module.ts
import { RetryModule } from '@backendkit-labs/retry/nestjs';

@Module({
  imports: [
    RetryModule.forRoot({
      engineConfig: {
        name: 'default',
        defaultConfig: {
          maxAttempts: 3,
          backoff: { type: 'exponential', baseDelay: 200, jitter: 'full' },
        },
      },
    }),
  ],
})
export class AppModule {}

// payment.service.ts
import { Retry, RetryService } from '@backendkit-labs/retry/nestjs';

@Injectable()
export class PaymentService {
  constructor(private readonly retry: RetryService) {}

  // Option 1 — inject RetryService and call .execute()
  async charge(order: Order) {
    const result = await this.retry.execute(
      () => this.gateway.charge(order),
      { maxAttempts: 4, backoff: { type: 'exponential', baseDelay: 300 } },
    );
    if (!result.ok) throw new ServiceUnavailableException(result.error.message);
    return result.value;
  }

  // Option 2 — @Retry() decorator + RetryInterceptor
  @Retry({ maxAttempts: 3, backoff: { type: 'exponential', baseDelay: 150 } })
  async reserveStock(sku: string, qty: number) {
    return this.inventoryApi.post('/reserve', { sku, qty });
  }
}

Idempotency#

Non-idempotent operations (payment charges, email sends, inventory deductions) can succeed server-side even when the response never reaches the client. A naive retry would execute the operation twice. The idempotency layer prevents this: the first successful result is serialized and cached; subsequent calls with the same key return the cached value immediately — the task is never invoked again.

PropTypeDescription
enabledbooleanMust be true to activate idempotency.
keystringUnique key for this logical operation, e.g. "charge:order-123".
storeIdempotencyStoreStorage backend. Implement IdempotencyStore for Redis, SQL, etc.
ttlMsnumberHow long to keep the cached result (ms). Default: 24 h.

InMemoryIdempotencyStore is process-local and loses state on restart. For production, implement IdempotencyStore backed by Redis or a database so all instances share the same cache.

Examples#

From basic to production-grade — copy and adapt.

payment.service.ts
import { retry } from '@backendkit-labs/retry';

const result = await retry(
  () => paymentGateway.charge(orderId, amount),
  {
    maxAttempts: 4,
    backoff: { type: 'exponential', baseDelay: 300, maxDelay: 5_000, jitter: 'full' },
    hooks: {
      beforeRetry: ({ attempt, delayMs, error }) =>
        logger.warn(`Payment retry ${attempt}: ${error.message} — waiting ${Math.round(delayMs)}ms`),
      onExhausted: ({ totalAttempts }) =>
        metrics.increment('payment.exhausted', { attempts: totalAttempts }),
    },
  },
);

if (result.ok) {
  return { transactionId: result.value.id };
} else {
  const { type, message, metadata } = result.error;
  throw new PaymentException(message, { type, attempts: metadata.attempts });
}