Robert Stoia

Creating an Idempotent REST API with NestJS

Originally published on Medium on July 10, 2024. The code examples below are preserved from the original article.

Illustration accompanying the NestJS REST API idempotency guide

What is idempotency?

REST (Representational State Transfer) is a set of design principles for an HTTP API. Common HTTP methods include GET, POST, PUT, and DELETE.

We can classify their defined semantics as follows:

  • GET: safe and idempotent.
  • POST: not safe and not inherently idempotent.
  • PUT: not safe, but idempotent.
  • DELETE: not safe, but idempotent.

GET is safe because its intended purpose is to retrieve information without changing server state. The other methods can change that state.

An idempotent operation has the same intended effect on server state when repeated as it does when performed once. This matters when a client retries a request.

For example, a POST request might successfully save a product, but its response could fail to reach the client. If the client sends the request again, an implementation without duplicate handling could create a second product.

Generate a request key

The approach explored in this article stores a request key alongside the product, using separate database tables. A subsequent request with the same key can look up the existing product.

In this example, middleware derives the key from the request body and places it in the idempotency-key header:

export default class ProductsMiddleware implements NestMiddleware {
  use(req: Request, _: Response, next: NextFunction) {
    const hash = createHash('sha1');

    hash.update(JSON.stringify(req.body));
    req.headers['idempotency-key'] = hash.digest('hex');

    next();
  }
}

This keeps key generation on the server for this example. Identical serialized request bodies produce the same hash.

Transactions

When a request arrives, the service checks for the key. If it does not exist, it saves both the key and the product in a transaction. If either write fails, the transaction can roll back both changes.

If the key already exists, the example returns the associated product instead of creating another one.

async create(
    product: CreateProductDto,
    idempotencyKey: string,
  ): Promise<Product> {
    const productsRepository = this.getProductRepository();
    const queryRunner = this.dataSource.createQueryRunner();
    let newProduct = productsRepository.create(product);

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const key = await queryRunner.manager.findOneBy(IdempotencyKey, {
        key: idempotencyKey,
      });

      if (!key) {
        const newKey = new IdempotencyKey();
        newKey.key = idempotencyKey;

        const key = await queryRunner.manager.save(newKey);
        newProduct.idempotencyKey = key;
        newProduct = await queryRunner.manager.save(newProduct);

        await queryRunner.commitTransaction();
      } else {
        newProduct = await productsRepository.findOne({
          where: { idempotencyKey: key },
        });
      }
    } catch (err) {
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }

    return newProduct;
  }

Define the entities

The product and its request key are connected through a TypeORM @OneToOne() relationship.

Product

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  price: number;

  @Column()
  description: string;

  @Column()
  quantity: number;

  @OneToOne(() => IdempotencyKey, {
    onDelete: 'CASCADE',
  })
  @JoinColumn()
  idempotencyKey: IdempotencyKey;
}

Idempotency key

@Entity()
export class IdempotencyKey {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  key: string;
}

@JoinColumn() marks the owning side of the relationship, where the foreign key is stored. The onDelete: 'CASCADE' setting means that deleting an idempotency-key record also deletes the associated product.

Delete a product

The delete method loads the product together with its key, then removes the key. The database relationship handles deletion of the associated product.

async delete(id: string): Promise<Product> {
    const productsRepository = this.getProductRepository();
    const product = await productsRepository.findOne({
      where: { id: +id },
      relations: ['idempotencyKey'],
    });
    const queryRunner = this.dataSource.createQueryRunner();

    if (!product) {
      throw new NotFoundException(`Product with id ${id} not found`);
    }

    await queryRunner.manager.remove(IdempotencyKey, product.idempotencyKey);

    return product;
  }

Example scope

This original implementation illustrates request deduplication, but it is not a complete production idempotency mechanism. Body-derived keys also conflate intentional identical operations, and hashes can collide. Concurrent requests need database uniqueness and conflict handling; the example does not define a unique constraint on the key. Its transaction handling also needs review: the existing-key branch does not explicitly complete the transaction, and the catch block suppresses the error. Deleting the key removes the record used to recognize later retries.

Source code

The complete example is available in the products-service repository on GitHub.