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:
GET /api/v1/products/665f1c2e8b3a4d0012a10006HTTP/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"
}| Key | Present | Meaning |
|---|---|---|
success | Always | false for every error |
message | Always | A sentence that is safe to show to a person |
code | Always | A stable identifier in capital letters, such as RESOURCE_NOT_FOUND. Compare this value in code |
issues | Validation failures | One entry per problem, with path, message and code. See Validation |
hint | When exposeErrorDetails is on | Advice for the developer, such as the roles a route allows |
details | When exposeErrorDetails is on and the error has any | Structured data, such as the unique fields that clashed |
requestId | Always | The 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.
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.
| Status | Class | When it is used |
|---|---|---|
| 400 | BadRequestError | The request is malformed: invalid JSON, a bad id or a bad query |
| 401 | UnauthorizedError | The caller is not signed in, or the credentials or the token are wrong |
| 403 | ForbiddenError | The caller is signed in but may not do this, or the request comes from a site that is not allowed |
| 404 | NotFoundError | The route or the document does not exist |
| 409 | ConflictError | The request clashes with stored data, such as a duplicate unique value |
| 413 | PayloadTooLargeError | A body or an uploaded file is too large |
| 415 | UnsupportedMediaTypeError | A file type or a text encoding is not allowed |
| 422 | ValidationError | The body broke a validation rule. It carries issues |
| 429 | TooManyRequestsError | A rate limit or an attempt limit was reached |
| 500 | InternalError, ConfigurationError | A bug, or a setup that is wrong |
| 502 | BadGatewayError | A service that StarkForge called, such as a social sign-in provider, failed |
| 503 | ServiceUnavailableError | The 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:
| Situation | Status | Code | Message |
|---|---|---|---|
| A protected route is called without a session cookie or bearer token | 401 | ACCESS_TOKEN_MISSING | Authentication is required. |
| The caller has a role that the route does not list | 403 | ROLE_NOT_ALLOWED | Your role is not allowed to do this. |
| The route needs an administrator | 403 | ADMIN_REQUIRED | Administrator access is required. |
| A user asks for a document that belongs to someone else | 403 | OWNER_REQUIRED | You can only access your own data. |
| A page on another site sends a state-changing request | 403 | CSRF_ORIGIN_REJECTED | Request origin is not allowed. |
| The id in the URL is malformed | 400 | INVALID_OBJECT_ID | The id in the URL is not valid. |
| The body is not valid JSON | 400 | INVALID_JSON | Invalid JSON in request body. |
| The id is well formed but no document has it | 404 | RESOURCE_NOT_FOUND | Product not found. |
| The URL matches no route | 404 | ROUTE_NOT_FOUND | Route not found: GET /api/v1/nothing-here |
| The body broke a validation rule | 422 | VALIDATION_FAILED | The first issue, written as path: message |
| A unique value already exists | 409 | DUPLICATE_KEY | An item with this sku already exists. |
The body is larger than bodyLimit | 413 | PAYLOAD_TOO_LARGE | Request 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:
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.
// 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:
// 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:
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:
| Option | Meaning |
|---|---|
message | Required. The text that the client receives |
code | A 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 |
hint | Advice for developers. It is sent only when exposeErrorDetails is on |
details | Extra structured data. It is sent only when exposeErrorDetails is on, so never put secrets in it |
headers | Response headers to add, such as Retry-After |
cause | The original error. It goes to the log and is never sent |
isOperational | true 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 thrown | The client receives |
|---|---|
| A Zod error | 422 VALIDATION_FAILED, with issues |
| A Mongoose validation error | 422 VALIDATION_FAILED, with issues |
| A MongoDB duplicate key error | 409 DUPLICATE_KEY, naming the fields |
| A Mongoose cast error, such as a bad id | 400 INVALID_OBJECT_ID |
| A lost database connection | 503 DATABASE_UNAVAILABLE |
| A body that is not valid JSON | 400 INVALID_JSON |
A body larger than bodyLimit | 413 PAYLOAD_TOO_LARGE |
| A request body that uses an unsupported text encoding | 415 UNSUPPORTED_MEDIA_TYPE |
| An expired or invalid token | 401 ACCESS_TOKEN_EXPIRED or ACCESS_TOKEN_INVALID |
| An upload error | 413 UPLOAD_TOO_LARGE, or 400 UPLOAD_TOO_MANY_FILES, UPLOAD_FIELD_NOT_FOUND or UPLOAD_FAILED |
| Anything else | 500 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:
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:
[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.
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:
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.
:::