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
- Fingerprinting — Deep dive into fingerprints
- Testing — Test idempotent code