Real World Example

Idempotency for Payments

Mobile networks in India drop often. If a "Pay" request times out, the app retries, and the customer must not be charged twice. Razorpay and Stripe solve this with an Idempotency-Key header; an interceptor implements it.

src/payments/idempotency.interceptor.ts

import { CallHandler, ConflictException, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { Observable, from, of, switchMap, tap } from 'rxjs';

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = context.switchToHttp().getRequest();
    const key = req.headers['idempotency-key'] as string | undefined;
    if (!key) return next.handle();
    const cacheKey = `idem:${req.user.id}:${key}`;

    return from(this.cache.get<{ state: string; body?: unknown }>(cacheKey)).pipe(
      switchMap((saved) => {
        if (saved?.state === 'done') return of(saved.body);            // replay first response
        if (saved?.state === 'running') throw new ConflictException('Request in progress');
        return from(this.cache.set(cacheKey, { state: 'running' }, 60_000)).pipe(
          switchMap(() => next.handle()),
          tap((body) => this.cache.set(cacheKey, { state: 'done', body }, 24 * 3600_000)),
        );
      }),
    );
  }
}

Example

The core logic, runnable: the second call with the same key gets the saved response and the charge happens once.

const store = new Map<string, unknown>();
let charges = 0;

async function charge(amount: number) {
  charges++;
  return { paymentId: 'pay_' + charges, amount, status: 'captured' };
}

async function idempotent<T>(key: string | undefined, work: () => Promise<T>): Promise<T> {
  if (!key) return work();
  if (store.has(key)) {
    console.log('  replaying saved response for', key);
    return store.get(key) as T;
  }
  const result = await work();
  store.set(key, result);
  return result;
}

async function main() {
  console.log(await idempotent('7f1c-22', () => charge(129900)));
  console.log(await idempotent('7f1c-22', () => charge(129900)));   // app retried
  console.log(await idempotent('9a0b-31', () => charge(49900)));
  console.log('total charges:', charges);
}
main();

Output

{ paymentId: 'pay_1', amount: 129900, status: 'captured' }
  replaying saved response for 7f1c-22
{ paymentId: 'pay_1', amount: 129900, status: 'captured' }
{ paymentId: 'pay_2', amount: 49900, status: 'captured' }
total charges: 2