Errors

Every error the framework raises carries four things: a code you can branch on, an HTTP status, a message describing what happened, and usually a hint describing how to fix it.

import server, { ServerError } from '@server/next';

server({
  onError: (error, ctx) => {
    error.code;     // 'UPLOAD_TOO_LARGE'
    error.status;   // 413
    error.message;  // 'File "avatar.png" is too large (4200000 bytes, limit is 10mb)'
    error.hint;     // "Raise it with `uploads: { bucket, maxFileSize: '50mb' }` ..."
    return new Response(error.message, { status: error.status });
  },
});

What reaches the client

The message and the hint have different audiences, so they go to different places.

A 4xx describes what the client got wrong, so its message is sent to them. A 5xx describes what went wrong inside the app, so the client only gets Server Error and the real message, its hint and its stack go to your log. That holds for errors your own handlers throw too: a throw new Error(...) has no status, so it is a 500, so its message never leaves the server.

client receiveslog receives
4xxthe messagenothing, it isn't your bug
5xxServer Errormessage, hint, docs link, stack
a handler's own throwServer Errorthe error, in full

The hint is never sent in production, whatever the status: it names your options and your environment variables, which is information the client has no use for.

In development

When NODE_ENV is not production and the request came from a browser, an error renders as a page with the message, the hint and a link to this reference. Anything else (a fetch, curl, an SDK) still gets the plain body, so your API behaves the same in every environment.

The framework also prints one line at startup so a development build is never mistaken for a deployed one:

[server:app] Running in development mode. Set NODE_ENV=production when you deploy.

Handling them yourself

onError receives the error and the context, and whatever it returns is sent. Branch on error.code rather than on the message, which is prose and may change:

server({
  onError: (error, ctx) => {
    if (error.code === 'UPLOAD_TOO_LARGE') {
      return { error: 'That file is too big, keep it under 10MB' };
    }
    if (error.status >= 500) report(error);   // your own logging
    return new Response(error.message, { status: error.status || 500 });
  },
});

A validation failure carries the failing fields, so an API can answer with them:

if (error.code === 'INVALID_REQUEST') {
  return status(422).json({ errors: error.issues });
}

Throwing your own

ServerError takes a code, a status and a message, and behaves exactly like the built-in ones:

import { ServerError } from '@server/next';

throw new ServerError('TEAM_FULL', 409, 'This team already has 10 members');

Register codes you use often and they become factories, with {placeholders} filled from the argument:

ServerError.extend({
  TEAM_FULL: { status: 409, message: 'The team "{name}" already has {max} members' },
});

throw ServerError.TEAM_FULL({ name: 'Ops', max: '10' });

Reference

The request

Raised before your handler runs.

NOT_FOUND

404 Not Found

No route matched. Register a catch-all last to answer with your own page, since routes are tried in the order they were added and the first match wins:

server()
  .get('/', Home)
  .get('/about', About)
  .get(() => <MissingPage />);   // no path: matches everything left

METHOD_NOT_ALLOWED

405 The HTTP method "{method}" is not supported

Only GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS are routed. A client sending anything else is usually a proxy or a scanner.

PATH_TRAVERSAL

400 The route param '{param}' tries to climb the path ('{value}')

A route param pointed outside where it belongs. If this route legitimately receives paths, set security: { traversalProtection: false }.

INVALID_REQUEST

422 Invalid request body

The route's schema rejected the request. The failing fields are on error.issues, which a custom onError can shape into an API response.

VALIDATION_FAILED

500 Server Error

The handler returned something its own response schema rejects, so this is a bug in the route rather than in the request. The failing fields are on error.issues.

The body

Raised while ctx.body is being parsed.

BODY_TOO_LARGE

413 Request body exceeds the {limit} limit

Raise it with security: { maxBodySize: '10mb' }, or maxBodySize: false to disable the cap. It only bounds what is held in memory; uploaded files stream to uploads and have their own limits.

BODY_INVALID_MULTIPART

400 A multipart/form-data body needs a boundary

The client set Content-Type: multipart/form-data by hand. Let it be set automatically (send a FormData and omit the header) so the boundary is included.

Uploads

Raised while a file is being stored. See uploads for the limits and their defaults.

UPLOAD_NOT_CONFIGURED

500 A file ("{name}") was uploaded but uploads is not configured

Set uploads: './uploads' (or a Bucket) to store files, or uploads: false to ignore file fields on purpose.

UPLOAD_TOO_LARGE

413 File "{name}" is too large ({size} bytes, limit is {limit})

Raise it with uploads: { bucket, maxFileSize: '50mb' }. maxTotalSize bounds one request's files together, and both default to 10mb and 100mb.

UPLOAD_TOO_MANY_FILES

413 Too many files in one request (the limit is {limit})

Raise it with uploads: { bucket, maxFiles: 500 }. It defaults to 100, which bounds how many objects one request can create.

UPLOAD_TOO_SMALL

400 File "{name}" is too small ({size} bytes, minimum is {limit})

Set or lower uploads: { bucket, minSize: '1kb' }.

UPLOAD_TYPE_NOT_ALLOWED

415 File type not allowed for "{name}" (got "{type}", allowed: {allowed})

fileType accepts extensions (.jpg) and MIME types (image/jpeg). It is checked against the file's real format when the bytes identify one, so a mislabelled file is refused even if its name matches.

Authentication

Raised while a credential is read or a login is completed. See Authentication.

AUTH_INVALID_TOKEN

401 Invalid Authorization token

The bearer token did not verify: check the issuer and audience, and that the token has not expired.

AUTH_INVALID_HEADER

401 Invalid authorization header {type}, must send 'Bearer {TOKEN}' (with space)

The Authorization header must read Bearer <token>, with a space.

AUTH_INVALID_STATE

403 Invalid OAuth state

The OAuth state cookie was missing or did not match. It is signed with secrets, lives for 10 minutes, and needs the callback to be on the same origin as the login.

AUTH_ISSUER_UNREACHABLE

502 Cannot reach the OIDC issuer at <url>

The issuer's discovery document could not be fetched. Check the issuer URL (it must serve /.well-known/openid-configuration) and that this server has network access to it. A cookie or token is never cleared on this error, since the credential itself may be perfectly valid.

AUTH_NO_CODE

400 Missing the OAuth 'code' in the callback URL

The provider redirected back without a code. Check the callback URL registered with the provider matches /auth/callback/<name>.

See also