Ensuring Idempotence: A Guide to Implementing Idempotent Endpoints with NestJS Interceptors
Eduardo Conti
Posted on January 11, 2024
Introduction
Idempotence refers to the property of an operation where applying it multiple times has the same result as applying it once. In the context of APIs, an idempotent operation ensures that making the same request multiple times produces the same outcome as making it once.
Idempotence is related to the effect that an operation has when executed multiple times, and this varies depending on the HTTP method used. Let's analyze the GET and POST methods.
GET:
Idempotent: The read operation does not change the server state. Requesting the same data multiple times does not cause changes to the server. Therefore, the GET method is naturally idempotent.
POST:
Non-idempotent: The operation of creating or sending data via POST usually changes the server state, creating a new resource. Each POST request can create a new resource or have different effects, so it is not idempotent.
The main distinction is that idempotent operations can be repeated without causing different side effects, while non-idempotent operations can lead to different results with each execution. This is crucial to ensure consistency and predictability in API design and server-side data manipulation.
Why Do I Need Idempotency?
Imagine you're managing an online booking system for concert tickets, and your application allows users to reserve tickets for their favorite band's upcoming show. Here's the situation:
A user initiates a ticket reservation by sending an HTTP POST request to the server, specifying the number of tickets and other relevant details.
The server receives the request and successfully reserves the tickets for the user, updating the database accordingly.
However, due to a temporary network issue or a sudden app crash, the client does not receive the successful response from the server.
The user, unaware that their reservation was successful, assumes it failed and decides to retry the reservation by sending another identical HTTP POST request.
The server processes the second request, not recognizing that it's a duplicate, and reserves the same set of tickets again.
Now, when the user checks their reservation history, they are surprised to see two identical reservations, and they have been charged for both sets of tickets.
This undesirable situation occurred because the system lacked idempotence. Ideally, an idempotent system would recognize that the second reservation request is a duplicate and would respond with a status indicating that the request has already been processed or the same response of first request. This way, the user wouldn't inadvertently end up with multiple reservations and duplicate charges.
Implementing idempotence in this scenario would ensure that even if the client retries the reservation due to a lack of response, the server would handle it safely, preventing unintended consequences such as duplicate reservations and charges.
Get Started
To implement an idempotent endpoint, I will use an API that I recently developed for this article: NestJS with RabbitMQ in a Monorepo: Building a Scalable Credit Card Payment System with Decoupled API and Consumers. As we are dealing with credit card charges, we don't want to risk duplicates.
Let's Go To The Implementation
Why Am I Using an NestJS Interceptor?
Because they make it possible to bind extra logic before / after method execution.
In this approach, I will initially pre-save the idempotence entity. Subsequently, after the controller execution, I will update it with the response.
Create class IdempotencyKeyInterceptor
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
BadRequestException,
Inject,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { IdempotencyRepository } from '../repositories';
import { IIdempotencyRepository } from '@api/idempotency/domain/repositories';
@Injectable()
export class IdempotencyKeyInterceptor implements NestInterceptor {
constructor(
@Inject(IdempotencyRepository)
private readonly idempotencyRepository: IIdempotencyRepository,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const idempotencyKey = request.headers['x-idempotency-key'];
if (!idempotencyKey) {
throw new BadRequestException(
"Header 'x-idempotency-key' is required for this request.",
);
}
if (!this.isValidUUID(idempotencyKey)) {
throw new BadRequestException(
"Header 'x-idempotency-key' must be a UUID.",
);
}
const idempotencyModel = await this.idempotencyRepository.find(
idempotencyKey,
);
if (idempotencyModel) {
return of(idempotencyModel.response);
}
await this.idempotencyRepository.preSave(idempotencyKey);
return next.handle().pipe(
tap(async (data) => {
await this.idempotencyRepository.update(idempotencyKey, data);
return data;
}),
);
}
private isValidUUID(uuid: string) {
const uuidRegex =
/(?:^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$)|(?:^0{8}-0{4}-0{4}-0{4}-0{12}$)/u;
return uuidRegex.test(uuid);
}
}
Dependencies Injection:
The class constructor injects an implementation of the IIdempotencyRepository interface through the IdempotencyRepository. This is a clear example of dependency injection, allowing for the flexibility to swap out different implementations of the repository.Interception Logic:
The intercept method is the core logic of the interceptor. It checks for the presence of an 'x-idempotency-key' header in the incoming request. If not present, it throws a BadRequestException. It also ensures that the idempotency key is a valid UUID.
It then queries the IdempotencyRepository to check if there's an existing entry for the idempotency key. If found, it short-circuits the request by returning the stored response.
If no existing entry is found, it pre-saves the idempotency key using the repository before allowing the request to proceed to the controller.
After the controller execution, it updates the idempotency entry with the actual response from the controller.Idempotency Repository Usage:
The class utilizes methods from the injected IdempotencyRepository to interact with the underlying storage. This repository handles tasks such as finding, pre-saving, and updating idempotency entries.Dependency Inversion:
By injecting the IdempotencyRepository interface, the class adheres to the dependency inversion principle. This design pattern allows for a more modular and testable codebase, where the specific implementation details of the repository can be swapped out without affecting the functionality of the interceptor.
you can see the creation and implementation of this interceptor and the repository (in memory) in this commit.
Be Careful
Please exercise caution, as I haven't applied this approach in a production environment. If you identify any issues with this implementation or have suggestions for enhancement, I welcome and appreciate your valuable insights. Feel free to share your opinions and recommendations.
Conclusion
In the pursuit of ensuring idempotence in API endpoints, this article navigated through the fundamental concepts, explored the distinctions between idempotent and non-idempotent operations, and delved into the importance of maintaining consistency and predictability in API design. Drawing attention to the potential pitfalls of lacking idempotence, a real-world scenario illuminated the need for robust solutions.
The journey continued with a practical implementation using NestJS Interceptors, showcasing the development of an IdempotencyKeyInterceptor. Leveraging the principles of dependency inversion, the interceptor seamlessly integrated with an Idempotency Repository, providing a modular and testable solution.
The detailed breakdown of the interceptor's logic, from dependency injection to interception strategy, highlighted its role in handling idempotency gracefully. The careful design allowed for pre-saving idempotent entities and updating them post-controller execution, introducing an effective approach to address the challenges posed by non-idempotent scenarios.
As we embark on implementing idempotent endpoints, the cautionary note underscores the importance of prudence. The class, though well-architected and designed, has not been battle-tested in a production setting. It beckons the community to scrutinize and contribute, welcoming opinions, identifying potential pitfalls, and offering suggestions for refinement.
In the spirit of collaborative improvement, this article encourages readers to share their experiences and insights, fostering a collective endeavor towards more robust, reliable, and idempotent API solutions. With a foundation laid in understanding, implementation, and community collaboration, the journey towards ensuring idempotence is well underway.
repo: nestjs-rabitmq-example
links:
Posted on January 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024