Skip to content

Service API ​

Use IdempotencyService for programmatic control.

Service Injection ​

typescript
import { Injectable, Inject } from '@nestjs/common';
import {
  IDEMPOTENCY_SERVICE,
  IIdempotencyService,
} from '@nestjs-redisx/idempotency';

@Injectable()
export class PaymentService {
  constructor(
    @Inject(IDEMPOTENCY_SERVICE)
    private readonly idempotency: IIdempotencyService,
  ) {}
}

checkAndLock() Method ​

Check if key exists and acquire lock if new:

typescript
async processPayment(key: string, dto: PaymentDto): Promise<Payment> {
  const fingerprint = this.generateFingerprint(dto);

  const result = await this.idempotency.checkAndLock(key, fingerprint, {
    ttl: 86400,
  });

  if (!result.isNew && result.record?.status === 'completed') {
    // Return cached response
    return JSON.parse(result.record.response);
  }

  // New request - proceed with processing
  try {
    const payment = await this.doPayment(dto);
    await this.idempotency.complete(key, {
      statusCode: 201,
      body: payment,
    });
    return payment;
  } catch (error) {
    await this.idempotency.fail(key, error.message);
    throw error;
  }
}

INFO

Concurrent request handling (waiting for a lock held by another request) is done automatically inside checkAndLock(). If the key is being processed, the method polls until completion or timeout, then returns the cached result.

complete() Method ​

Mark operation as successfully completed:

typescript
async createOrder(key: string, dto: CreateOrderDto): Promise<Order> {
  const fingerprint = createHash('sha256')
    .update(JSON.stringify(dto))
    .digest('hex');

  await this.idempotency.checkAndLock(key, fingerprint);

  const order = await this.orderService.create(dto);
  await this.emailService.send(order);

  await this.idempotency.complete(key, {
    statusCode: 201,
    body: order,
    headers: {
      Location: `/orders/${order.id}`,
    },
  });

  return order;
}

fail() Method ​

Mark operation as failed (takes key and error message):

typescript
async processRequest(key: string, fingerprint: string): Promise<void> {
  await this.idempotency.checkAndLock(key, fingerprint);

  try {
    await this.doRiskyOperation();
    await this.idempotency.complete(key, {
      statusCode: 200,
      body: { success: true },
    });
  } catch (error) {
    // Store error for replay
    await this.idempotency.fail(key, error.message);
    throw error;
  }
}

get() Method ​

Retrieve idempotency record:

typescript
async getStatus(key: string): Promise<IdempotencyStatus> {
  const record = await this.idempotency.get(key);

  if (!record) {
    return { exists: false };
  }

  return {
    exists: true,
    status: record.status,
    completedAt: record.completedAt,
  };
}

delete() Method ​

Remove idempotency record:

typescript
async cancelOperation(key: string): Promise<void> {
  const record = await this.idempotency.get(key);

  if (record && record.status === 'processing') {
    throw new BadRequestException('Operation in progress');
  }

  await this.idempotency.delete(key);
}

Manual Implementation Example ​

typescript
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
import { createHash } from 'crypto';
import {
  IDEMPOTENCY_SERVICE,
  IIdempotencyService,
} from '@nestjs-redisx/idempotency';
import { PaymentDto, PaymentResponse, PaymentGateway } from './types';

@Injectable()
export class ManualIdempotencyService {
  constructor(
    @Inject(IDEMPOTENCY_SERVICE)
    private readonly idempotency: IIdempotencyService,
    private readonly paymentGateway: PaymentGateway,
  ) {}

  async createPayment(
    key: string,
    dto: PaymentDto,
  ): Promise<PaymentResponse> {
    // 1. Generate fingerprint
    const fingerprint = createHash('sha256')
      .update(JSON.stringify(dto))
      .digest('hex');

    // 2. Check and lock (handles concurrent requests internally)
    const result = await this.idempotency.checkAndLock(key, fingerprint, {
      ttl: 86400,
    });

    // 3. Handle existing record
    if (!result.isNew && result.record) {
      if (result.record.status === 'completed') {
        return {
          statusCode: result.record.statusCode,
          body: JSON.parse(result.record.response),
          headers: JSON.parse(result.record.headers || '{}'),
        };
      }

      if (result.record.status === 'failed') {
        throw new BadRequestException(result.record.error);
      }
    }

    // 4. Process new request
    try {
      const payment = await this.paymentGateway.charge(dto);

      await this.idempotency.complete(key, {
        statusCode: 201,
        body: payment,
        headers: {
          'X-Payment-Id': payment.id,
        },
      });

      return {
        statusCode: 201,
        body: payment,
        headers: {
          'X-Payment-Id': payment.id,
        },
      };
    } catch (error) {
      await this.idempotency.fail(key, error.message);
      throw error;
    }
  }
}

Background Jobs ​

Use idempotency for job processing:

typescript
import { Injectable, Inject } from '@nestjs/common';
import {
  IDEMPOTENCY_SERVICE,
  IIdempotencyService,
} from '@nestjs-redisx/idempotency';

@Injectable()
export class JobProcessor {
  constructor(
    @Inject(IDEMPOTENCY_SERVICE)
    private readonly idempotency: IIdempotencyService,
  ) {}

  async processJob(jobId: string, data: any): Promise<void> {
    const key = `job:${jobId}`;

    // checkAndLock handles concurrent requests internally (waits if locked)
    const result = await this.idempotency.checkAndLock(key, jobId, {
      ttl: 3600,
    });

    if (!result.isNew) {
      console.log('Job already processed, skipping');
      return;
    }

    try {
      await this.doWork(data);
      await this.idempotency.complete(key, {
        statusCode: 200,
        body: { success: true },
      });
    } catch (error) {
      await this.idempotency.fail(key, error.message);
      throw error;
    }
  }

  private async doWork(data: any): Promise<void> {
    // Process job data
  }
}

Batch Operations ​

typescript
import { Injectable, Inject } from '@nestjs/common';
import { createHash } from 'crypto';
import {
  IDEMPOTENCY_SERVICE,
  IIdempotencyService,
} from '@nestjs-redisx/idempotency';
import { BatchItem, BatchResult } from './types';

@Injectable()
export class BatchService {
  constructor(
    @Inject(IDEMPOTENCY_SERVICE)
    private readonly idempotency: IIdempotencyService,
  ) {}

  async processBatch(items: BatchItem[]): Promise<BatchResult[]> {
    const results: BatchResult[] = [];

    for (const item of items) {
      const key = `batch:${item.id}`;
      const fingerprint = createHash('sha256')
        .update(JSON.stringify(item))
        .digest('hex');

      const check = await this.idempotency.checkAndLock(key, fingerprint, {
        ttl: 3600,
      });

      if (!check.isNew && check.record?.status === 'completed') {
        results.push(JSON.parse(check.record.response));
        continue;
      }

      try {
        const result = await this.processItem(item);
        await this.idempotency.complete(key, {
          statusCode: 200,
          body: result,
        });
        results.push(result);
      } catch (error) {
        await this.idempotency.fail(key, error.message);
        results.push({ error: error.message });
      }
    }

    return results;
  }

  private async processItem(item: BatchItem): Promise<BatchResult> {
    return { id: item.id };
  }
}

Testing Support ​

Mock service for unit tests:

typescript
import { Test } from '@nestjs/testing';
import { describe, it, expect, beforeEach, vi, type MockedObject } from 'vitest';
import { IDEMPOTENCY_SERVICE, type IIdempotencyService } from '@nestjs-redisx/idempotency';

describe('PaymentService', () => {
  let service: PaymentService;
  let idempotency: MockedObject<IIdempotencyService>;

  beforeEach(async () => {
    const mockIdempotency: Partial<IIdempotencyService> = {
      checkAndLock: vi.fn(),
      complete: vi.fn(),
      fail: vi.fn(),
      get: vi.fn(),
      delete: vi.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        PaymentService,
        {
          provide: IDEMPOTENCY_SERVICE,
          useValue: mockIdempotency,
        },
      ],
    }).compile();

    service = module.get(PaymentService);
    idempotency = module.get(IDEMPOTENCY_SERVICE);
  });

  it('should process new request', async () => {
    idempotency.checkAndLock.mockResolvedValue({
      isNew: true,
    });

    await service.processPayment('pay-123', { amount: 100 });

    expect(idempotency.complete).toHaveBeenCalled();
  });

  it('should return cached response', async () => {
    idempotency.checkAndLock.mockResolvedValue({
      isNew: false,
      record: {
        key: 'pay-123',
        fingerprint: 'abc',
        status: 'completed',
        response: '{"id": 456}',
        statusCode: 201,
        startedAt: Date.now(),
        completedAt: Date.now(),
      },
    });

    const result = await service.processPayment('pay-123', { amount: 100 });

    expect(result).toEqual({ id: 456 });
    expect(idempotency.complete).not.toHaveBeenCalled();
  });
});

Next Steps ​

Released under the MIT License.