Skip to content
AB

Designing API Errors: Public Messages, Internal Causes, and Stable Codes

Give clients predictable errors while preserving the technical evidence operators need to diagnose failures.

Ashish BhakhandPublished

Returning error.message from an API looks convenient. It also leaks database details, produces unstable client behavior, and gives users messages written for developers.

I separate an error into two views: a stable public contract and its internal cause.

Code
export class AppError extends Error {
  constructor(
    readonly code: string,
    readonly statusCode: number,
    readonly publicMessage: string,
    options?: { cause?: unknown; operational?: boolean },
  ) {
    super(publicMessage, { cause: options?.cause });
    this.name = new.target.name;
  }
}

export class ResourceNotFoundError extends AppError {
  constructor() {
    super("RESOURCE_NOT_FOUND", 404, "The requested item was not found.");
  }
}

The client can branch on code, display publicMessage, and treat statusCode using normal HTTP semantics. The server logs the original cause and stack with a correlation ID.

Translate errors at boundaries#

Infrastructure errors should not escape directly:

Code
try {
  await collection.insertOne(document);
} catch (error) {
  if (isDuplicateKey(error)) {
    throw new EmailAlreadyRegisteredError({ cause: error });
  }

  throw new AppError("INTERNAL_ERROR", 500, "Something went wrong.", {
    cause: error,
  });
}

This translation keeps MongoDB error codes out of the API contract. A later storage change does not force clients to change.

Do not make every failure a 500#

Validation failures are usually 400, missing authentication is 401, insufficient permission is 403, absence is 404, and conflicts such as an already-used email are often 409. Choose semantics consistently and document stable codes.

Unknown exceptions are different. Log them as unexpected and return one generic 500 response. Do not guess a detailed public message from an error you do not understand.

Errors are part of the API#

Test the error code and status clients depend on. Avoid asserting internal database wording. Include a correlation ID in the response so support can connect a report to the detailed server log.

A useful error contract tells the caller what kind of failure occurred and what it may do next. It tells the operator what actually broke without telling the user anything unsafe.

← Back to all writing