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.