Why My Prisma P2002 Was Escaping try/catch in NestJS

I’m building RunHop in public, a social + event platform for running races. Today I worked on the Reactions module: likes on posts.

The module itself was straightforward:

  • POST /posts/:id/likes
  • DELETE /posts/:id/likes
  • a ReactionService that talks to Prisma’s postLike model
  • unit tests and e2e coverage for the happy path and duplicate-like path

The interesting bug was in the duplicate-like flow.

The Service Looked Correct

I had this shape in src/domain/social/reaction/reaction.service.ts:

async like(postId: string, userId: string) {
    const post = await this.postService.findById(postId);

    if (!post) throw new NotFoundException('Post not found');

    try {
        return this.prisma.postLike.create({
            data: { postId, userId },
        });
    } catch (error) {
        if (
            error instanceof PrismaClientKnownRequestError &&
            error.code === 'P2002'
        ) {
            throw new ConflictException('Duplicate like');
        }

        throw error;
    }
}

At a glance it feels fine. There’s a try/catch, Prisma throws P2002 for a unique constraint violation, and I map it to ConflictException.

But the unit test still failed.

Why It Failed
postLike.create() returns a promise. When that promise rejects, the rejection happens asynchronously.

Because I returned the promise directly from inside the try block, the function exited before the rejection occurred. The catch block never saw the Prisma error.

So instead of this:

Prisma rejects with P2002
service catches it
Nest gets ConflictException

I got this:

Prisma rejects with P2002
promise escapes
raw Prisma error bubbles up
The Fix
The fix was to await inside the try block:

try {
    return await this.prisma.postLike.create({
        data: { postId, userId },
    });
} catch (error) {
    if (
        error instanceof PrismaClientKnownRequestError &&
        error.code === 'P2002'
    ) {
        throw new ConflictException('Duplicate like');
    }

    throw error;
}

This is one of the few cases where return await is exactly what you want. It keeps the rejected promise inside the try/catch boundary.

The Rest of the Module

I also added:

ownershipCheck(likeId, userId) so only the owner can delete a like unlike() mapping Prisma P2025 to NotFoundException
an e2e test that does the full flow:
register
create post
like
duplicate like -> 409
unlike
unlike again -> 404

One design detail I still want to revisit: the delete route is DELETE /posts/:id/likes, but the :id there is currently a like id, not a post id. It works, but the route shape is carrying two meanings between create and delete.

Takeaway

The useful reminder from this session:

try/catch only catches what stays inside it.

If you need to translate async database errors into framework exceptions, you need the rejected promise to be awaited inside the try block.