RizTech Academy logo
RizTech Academy
REST APIs Done ProperlyLesson 3 of 530 min

Errors a client can actually act on

"Something went wrong" is not an error message. It tells the user nothing, tells the developer nothing, and turns a fixable problem into a support ticket. This lesson is about errors a client can do something with.

Three audiences

An error has to serve three people, and they want different things.

The user needs to know what to do: "That coupon has expired" or "Only 2 left in stock".

The client developer needs to know how to handle it programmatically — whether to retry, redirect to login, or show a field error.

You, at 2am, need the stack trace, the request and enough context to reproduce it.

The mistake is serving one of the three. A message written only for the user cannot be branched on; a stack trace sent to the browser is a security problem.

A consistent shape

Decide once and apply everywhere:

{
  "statusCode": 409,
  "code": "INSUFFICIENT_STOCK",
  "message": "Only 2 of Aashirvaad Atta 5kg are available",
  "details": { "variantId": "clx1", "requested": 5, "available": 2 },
  "path": "/api/orders",
  "timestamp": "2026-09-27T14:30:00.000Z"
}

statusCode for the category.

code is the important addition. A stable machine-readable string the client can switch on. Messages get reworded and translated; code does not.

switch (error.code) {
  case "INSUFFICIENT_STOCK":
    return showStockWarning(error.details);
  case "CART_EMPTY":
    return router.push("/products");
  default:
    return showGenericError();
}

Without code, a client ends up matching on message text, which breaks the first time somebody fixes a typo.

details carries structured context — the ids and numbers a client needs to render a useful message.

path and timestamp for correlating with your logs.

Defining the codes

// apps/api/src/common/errors/error-codes.ts
export const ErrorCode = {
  VALIDATION_FAILED: "VALIDATION_FAILED",
  UNAUTHENTICATED: "UNAUTHENTICATED",
  FORBIDDEN: "FORBIDDEN",
  NOT_FOUND: "NOT_FOUND",
  CART_EMPTY: "CART_EMPTY",
  INSUFFICIENT_STOCK: "INSUFFICIENT_STOCK",
  PRICE_CHANGED: "PRICE_CHANGED",
  ORDER_NOT_CANCELLABLE: "ORDER_NOT_CANCELLABLE",
  OUTSIDE_DELIVERY_AREA: "OUTSIDE_DELIVERY_AREA",
  PAYMENT_FAILED: "PAYMENT_FAILED",
} as const;

export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];

Keeping them in one file means you can see the whole vocabulary, and the client can import the same list from packages/shared — so a typo in a switch is a compile error.

Throwing them

// apps/api/src/common/errors/app.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common";
import { ErrorCode } from "./error-codes";

export class AppException extends HttpException {
  constructor(
    status: HttpStatus,
    public readonly code: ErrorCode,
    message: string,
    public readonly details?: Record<string, unknown>,
  ) {
    super({ code, message, details }, status);
  }
}

export class InsufficientStockException extends AppException {
  constructor(label: string, requested: number, available: number) {
    super(
      HttpStatus.CONFLICT,
      ErrorCode.INSUFFICIENT_STOCK,
      available === 0
        ? `${label} is out of stock`
        : `Only ${available} of ${label} are available`,
      { requested, available },
    );
  }
}
if (updated.count === 0) {
  throw new InsufficientStockException(variant.label, item.quantity, variant.stock);
}

The specific exception class does three things at once: picks the status, sets the code, and writes a message that reads properly for both the zero and partial cases. The service that throws it does not have to remember any of that.

Choosing the status code

From module 1, applied:

Situation Status Code
Malformed body 400 VALIDATION_FAILED
Not logged in 401 UNAUTHENTICATED
Logged in, not allowed 403 FORBIDDEN
No such product 404 NOT_FOUND
Out of stock at checkout 409 INSUFFICIENT_STOCK
Order cannot be cancelled now 409 ORDER_NOT_CANCELLABLE
Too many requests 429 RATE_LIMITED
Your bug 500 INTERNAL_ERROR

409 Conflict is the one people miss. It means the request was well-formed and legal, but clashes with the current state. Out of stock is not a 400 — the client did nothing wrong, the world changed. Getting this right lets a client distinguish "fix your request" from "try again or choose something else".

Never return 500 for something the user did. It says your server broke when it did not, and it will generate support tickets and alerts that waste your time.

Never leak internals

catch (error) {
  throw new InternalServerErrorException(error.message);     // do not
}

That can send connect ECONNREFUSED 10.0.1.4:5432 to a browser, which tells an attacker your internal network layout. Database errors can include table names, column names and sometimes data.

The filter from module 5, doing the job properly:

if (status >= 500) {
  this.logger.error(`${request.method} ${request.url}`, exception.stack);
  return response.status(status).json({
    statusCode: 500,
    code: ErrorCode.INTERNAL_ERROR,
    message: "Something went wrong on our side. Please try again.",
    requestId,
    path: request.url,
    timestamp: new Date().toISOString(),
  });
}

A generic message plus a request id. The user can quote the id to support, and you can find the exact log line. That is the honest version of "something went wrong".

Database errors

Prisma throws typed errors worth translating:

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
  catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();

    switch (exception.code) {
      case "P2002": {
        const field = (exception.meta?.target as string[])?.[0] ?? "value";
        return response.status(409).json({
          statusCode: 409,
          code: ErrorCode.ALREADY_EXISTS,
          message: `That ${field} is already taken`,
        });
      }
      case "P2025":
        return response.status(404).json({
          statusCode: 404,
          code: ErrorCode.NOT_FOUND,
          message: "That record does not exist",
        });
      default:
        return response.status(500).json({
          statusCode: 500,
          code: ErrorCode.INTERNAL_ERROR,
          message: "Something went wrong on our side.",
        });
    }
  }
}

P2002 is a unique constraint violation and P2025 is a record not found. Translating them means a duplicate email gives "That email is already taken" rather than a 500 — and it happens once, centrally, rather than in a try/catch around every create.

Messages worth writing

Three rules.

Say what to do next.

Invalid pincode                                          poor
Pincode must be 6 digits                                 better
We do not deliver to 411057 yet. Try another address.    best

Name the thing. "Only 2 available" is less useful than "Only 2 of Aashirvaad Atta 5kg are available" when a cart has five items.

Do not blame the user. "You entered an invalid value" reads worse than "That does not look like a 10-digit mobile number", and conveys the same information.

Errors are part of the contract

Document them. With Swagger:

@Post()
@ApiResponse({ status: 201, description: "Order created" })
@ApiResponse({ status: 409, description: "Insufficient stock or cart empty" })
@ApiResponse({ status: 422, description: "Outside the delivery area" })
create(@Body() dto: CreateOrderDto) { ... }

A client developer needs to know which failures are possible to handle them. An endpoint whose error cases are undocumented gets a generic catch-all on the client, which is how "Something went wrong" appears in interfaces.

Check your work

Three audiences for an error: the user, the client developer, and you debugging later.

Why a code field: messages get reworded and translated; a stable code is what a client can safely branch on.

Why 409 rather than 400 for out of stock: the request was well-formed and legal — the world changed. 400 would tell the client to fix a request that was correct.

Why never 500 for user error: it reports a server failure that did not happen, and generates alerts and tickets.

What to return for an actual 500: a generic message plus a request id, with the real detail logged.

What P2002 and P2025 mean: a unique constraint violation and a record not found. Translating them centrally avoids a try/catch on every write.

Best kind of error message: one that names the thing and says what to do next.

Practice

  1. Define your ErrorCode list in packages/shared so both halves import it.
  2. Write AppException and InsufficientStockException. Throw the second and check the response shape.
  3. Make the message differ between zero available and some available.
  4. Return 400 for out of stock, then change it to 409. Explain the difference to a client developer in one sentence.
  5. Throw a raw database error and confirm the message reaches the browser. Add the filter and confirm it no longer does.
  6. Add a request id to 500 responses and find the matching log line.
  7. Write the Prisma filter. Create a duplicate slug and confirm you get a 409 with a readable message rather than a 500.
  8. Take three of your error messages and rewrite them to say what to do next.
  9. Write a client-side switch on code handling three cases and a default.
  10. Document the error responses of one endpoint with Swagger.

Next: paginating, filtering and sorting as an API contract.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship