S
Errors

Errors

How StarkForge reports failures with one error shape, stable codes and the right HTTP status

When a request fails, StarkForge answers with one JSON shape, whatever went wrong: a failed validation, a missing sign-in, a duplicate value or a database that is down. The HTTP status tells a client what kind of problem happened, the message is a sentence that is safe to show, and the code names the exact problem so that client code can react to it. This page describes the shape, the statuses, the classes behind them and how to throw an error of your own.

The Error Envelope

Asking for a product that does not exist shows the basic shape:

Text
GET /api/v1/products/665f1c2e8b3a4d0012a10006
Text
HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "success": false,
  "message": "Product not found.",
  "code": "RESOURCE_NOT_FOUND",
  "requestId": "3cc3e1c0-0640-480c-8730-c771e87cff4e"
}
KeyPresentMeaning
successAlwaysfalse for every error
messageAlwaysA sentence that is safe to show to a person
codeAlwaysA stable identifier in capital letters, such as RESOURCE_NOT_FOUND. Compare this value in code
issuesValidation failuresOne entry per problem, with path, message and code. See Validation
hintWhen exposeErrorDetails is onAdvice for the developer, such as the roles a route allows
detailsWhen exposeErrorDetails is on and the error has anyStructured data, such as the unique fields that clashed
requestIdAlwaysThe id of the request, the same value as the X-Request-Id header. Quote it when you report a problem

The option exposeErrorDetails is on outside production and off in production, because hints and details can name schema fields and allowed roles. In production a client receives only the status, message, code, issues and requestId.

Switch on Codes

Make decisions from code, not from message. One status can have several codes: a 401 may mean a missing, an expired or an invalid token, and each needs a different reaction. Codes are stable across releases, and the wording of a message is not a contract.

Statuses and Error Classes

Each status has a class that you can throw. Every class extends AppError, and all of them are exported from @starklabs/forge, together with the ErrorCode object that holds every built-in code.

StatusClassWhen it is used
400BadRequestErrorThe request is malformed: invalid JSON, a bad id or a bad query
401UnauthorizedErrorThe caller is not signed in, or the credentials or the token are wrong
403ForbiddenErrorThe caller is signed in but may not do this, or the request comes from a site that is not allowed
404NotFoundErrorThe route or the document does not exist
409ConflictErrorThe request clashes with stored data, such as a duplicate unique value
413PayloadTooLargeErrorA body or an uploaded file is too large
415UnsupportedMediaTypeErrorA file type or a text encoding is not allowed
422ValidationErrorThe body broke a validation rule. It carries issues
429TooManyRequestsErrorA rate limit or an attempt limit was reached
500InternalError, ConfigurationErrorA bug, or a setup that is wrong
502BadGatewayErrorA service that StarkForge called, such as a social sign-in provider, failed
503ServiceUnavailableErrorThe database, the email provider or the file storage is not available

Error Handling lists every built-in code with its status and meaning.

The Failures You Meet First

These situations account for most errors during development:

SituationStatusCodeMessage
A protected route is called without a session cookie or bearer token401ACCESS_TOKEN_MISSINGAuthentication is required.
The caller has a role that the route does not list403ROLE_NOT_ALLOWEDYour role is not allowed to do this.
The route needs an administrator403ADMIN_REQUIREDAdministrator access is required.
A user asks for a document that belongs to someone else403OWNER_REQUIREDYou can only access your own data.
A page on another site sends a state-changing request403CSRF_ORIGIN_REJECTEDRequest origin is not allowed.
The id in the URL is malformed400INVALID_OBJECT_IDThe id in the URL is not valid.
The body is not valid JSON400INVALID_JSONInvalid JSON in request body.
The id is well formed but no document has it404RESOURCE_NOT_FOUNDProduct not found.
The URL matches no route404ROUTE_NOT_FOUNDRoute not found: GET /api/v1/nothing-here
The body broke a validation rule422VALIDATION_FAILEDThe first issue, written as path: message
A unique value already exists409DUPLICATE_KEYAn item with this sku already exists.
The body is larger than bodyLimit413PAYLOAD_TOO_LARGERequest body is too large.

The 403 for CSRF_ORIGIN_REJECTED appears when a browser sends a state-changing request with an Origin header that is not listed in frontendURL or frontendURLs. Add the origin of your front end there.

Rate Limit Errors

A client that goes over a limit gets 429 and the code RATE_LIMITED. The Retry-After header says how many seconds to wait, and the RateLimit headers describe the limit that was reached. This response came from a server with maxReqLimit set to 2:

Text
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 900
RateLimit: limit=2, remaining=0, reset=900
RateLimit-Policy: 2;w=900

{
  "success": false,
  "message": "Too many requests, please try again later.",
  "code": "RATE_LIMITED",
  "requestId": "a439a9fd-cc7f-42b9-a042-3b9acb203c04"
}

There are two limits. Every route shares the global limit for each IP address, which is 100 requests per 15 minutes by default. Sign-up, sign-in, one-time code and password reset routes also share a stricter limit of 20 requests in the same window. The text of the message comes from the option rateLimitMsg. Some sign-in steps count wrong attempts themselves and answer 429 with codes of their own, such as OTP_RESEND_COOLDOWN and MFA_LOCKED.

Reading Errors in a Client

A front end needs the same few checks for every request. This function turns each response into a result that a form can use: the data on success, one message per field for a failed validation, the seconds to wait for a rate limit, and the message and request id for everything else.

TypeScript
// src/client/request.ts
interface Issue {
  path: string;
  message: string;
}

interface ApiBody {
  success: boolean;
  message: string;
  data?: unknown;
  code?: string;
  issues?: Issue[];
  requestId?: string;
}

export async function request(path: string, init: RequestInit = {}) {
  // credentials "include" sends the session cookies when the front end and the API use different origins.
  const res = await fetch(`http://localhost:5000/api/v1${path}`, { credentials: "include", ...init });
  const body = (await res.json()) as ApiBody;

  if (body.success) return { ok: true as const, data: body.data };

  if (body.code === "VALIDATION_FAILED") {
    // One message per field, ready to show next to each input.
    const fieldErrors = Object.fromEntries((body.issues ?? []).map((issue) => [issue.path, issue.message]));
    return { ok: false as const, fieldErrors };
  }

  if (body.code === "RATE_LIMITED") {
    return { ok: false as const, retryAfterSeconds: Number(res.headers.get("Retry-After")) };
  }

  return { ok: false as const, message: body.message, requestId: body.requestId };
}

Throwing Your Own Errors

Code that runs inside a request, such as an action, can throw any of the classes. StarkForge turns the error into the response, so you never write a failure response yourself. This collection rejects titles that contain the word spam before the handler saves anything:

TypeScript
// src/collections/tasks.ts
import { collection, defineRoutes, fields, ForbiddenError } from "@starklabs/forge";

function rejectSpam(title: string) {
  if (title.toLowerCase().includes("spam")) {
    throw new ForbiddenError({
      message: "No spam, please.",
      code: "NO_SPAM",
      hint: "Titles may not contain the word spam.",
      details: { word: "spam" },
    });
  }
}

export const tasks = collection({
  type: "crud",
  route: "tasks",
  model: "Task",
  schema: { title: fields.requiredString },
  validations: { create: { title: fields.requiredString } },
  routes: defineRoutes([
    {
      method: "post",
      path: "/",
      handler: "create",
      auth: "public",
      validation: "create",
      actions: {
        before: [{ customAction: (ctx) => rejectSpam(String(ctx.req.body?.title ?? "")) }],
      },
    },
  ]),
});

A request with the title spam spam is answered like this:

Text
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "success": false,
  "message": "No spam, please.",
  "code": "NO_SPAM",
  "hint": "Titles may not contain the word spam.",
  "details": {
    "word": "spam"
  },
  "requestId": "00571262-51fb-4140-8c8e-20d06b62c51f"
}

With exposeErrorDetails off, the hint and details keys are left out and everything else stays the same. Every class takes one options object:

OptionMeaning
messageRequired. The text that the client receives
codeA code string. Each class has a default, and a code that you invent is fine. Use capital letters and underscores, as the built-in codes do
hintAdvice for developers. It is sent only when exposeErrorDetails is on
detailsExtra structured data. It is sent only when exposeErrorDetails is on, so never put secrets in it
headersResponse headers to add, such as Retry-After
causeThe original error. It goes to the log and is never sent
isOperationaltrue by default, which means the message is safe to show. With false, the client receives Something went wrong. unless exposeErrorDetails is on

AppError also accepts statusCode. The other classes fix their own status.

Library Errors Are Translated

Your own code can let an error from a library escape, for example from a Mongoose call inside an action. A central handler turns the common ones into the same envelope, so a client never sees them in another shape:

What was thrownThe client receives
A Zod error422 VALIDATION_FAILED, with issues
A Mongoose validation error422 VALIDATION_FAILED, with issues
A MongoDB duplicate key error409 DUPLICATE_KEY, naming the fields
A Mongoose cast error, such as a bad id400 INVALID_OBJECT_ID
A lost database connection503 DATABASE_UNAVAILABLE
A body that is not valid JSON400 INVALID_JSON
A body larger than bodyLimit413 PAYLOAD_TOO_LARGE
A request body that uses an unsupported text encoding415 UNSUPPORTED_MEDIA_TYPE
An expired or invalid token401 ACCESS_TOKEN_EXPIRED or ACCESS_TOKEN_INVALID
An upload error413 UPLOAD_TOO_LARGE, or 400 UPLOAD_TOO_MANY_FILES, UPLOAD_FIELD_NOT_FOUND or UPLOAD_FAILED
Anything else500 INTERNAL_ERROR

Unexpected Errors

An error that StarkForge does not recognize, such as an exception inside your own action, is answered with 500 and the code INTERNAL_ERROR. The message is always the generic Something went wrong., even when exposeErrorDetails is on, so an internal message such as a connection string never reaches a client:

Text
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{
  "success": false,
  "message": "Something went wrong.",
  "code": "INTERNAL_ERROR",
  "requestId": "66e22cf7-e2d0-4177-958b-19ed52f56d57"
}

The real error goes to the server log instead. The first line of the entry looks like this, and the lines after it hold the same requestId, the code, the status and the stack trace of the original error:

Text
[2026-01-15T10:30:00.000Z] ERROR POST /api/v1/tasks → 500 INTERNAL_ERROR: Something went wrong.

The id in the response therefore leads you to the cause.

Ask for the Request ID

When a user reports a failure, ask for the requestId from the response or the X-Request-Id header. It finds the matching log lines at once.

Errors at Start-Up

Some mistakes are found before the server listens, and they are exceptions rather than responses. new Forge() throws a ConfigurationError that lists every problem with the options at once:

Text
ConfigurationError (CONFIGURATION_ERROR): Invalid Forge configuration:
 - auth.mongooseConfig is required when auth.mode is 'builtin'.
 - jwtSecret must be at least 32 characters long.
hint: Fix the listed options in new Forge({...}).

Node prints the real error with a stack trace, followed by the properties code, hint and details. The start-up examples in this documentation condense that output to the name, the code, the message and the hint. Mistakes in a collection, such as an unknown handler, are reported by build() or start() in the same way, with a list of every problem found. A database that cannot be reached when the server starts throws a ServiceUnavailableError with the code DATABASE_UNAVAILABLE.

Where to Go Next

Actions

Run your own code around a handler and reject requests from it.

Validation

See how a failed validation reports every problem at once.

:::