For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/routing/errors.md.

Errors

Handlers and middlewares return their errors, built with the helpers of @dotpress/core. This page lists them, the errors Dotpress produces itself, and how to report the unexpected ones. For the principles, see Returned errors.

Returning an error

defineRoute<"/documents/:id", EmptyObject, false, unknown[]>(route: RouteConfig<"/documents/:id", EmptyObject, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/documents/:id", EmptyObject, EmptyObject, false, unknown[], unknown>.path: "/documents/:id"
path
: '/documents/:id',
RouteConfig<"/documents/:id", EmptyObject, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<EmptyObject, {
    id: string;
}, false, unknown>
handler
: async ({
req: RouteRequest<EmptyObject, {
    id: string;
}, false>
req
,
userId: string | undefined
userId
}) => {
const
const document: {
    id: string;
    ownerId: string;
} | undefined
document
= await
const documents: {
    find(id: string): Promise<{
        id: string;
        ownerId: string;
    } | undefined>;
}
documents
.
function find(id: string): Promise<{
    id: string;
    ownerId: string;
} | undefined>
find
(
req: RouteRequest<EmptyObject, {
    id: string;
}, false>
req
.
DotpressRequest<{ id: string; }, QueryValues, unknown, UploadedFiles>.params: {
    id: string;
}
params
.
id: string
id
)
if (!
const document: {
    id: string;
    ownerId: string;
} | undefined
document
) return
function notFoundError(message?: string, data?: ErrorData): HttpError
notFoundError
('Document not found')
if (
const document: {
    id: string;
    ownerId: string;
}
document
.
ownerId: string
ownerId
!==
userId: string | undefined
userId
) return
function forbiddenError(message?: string, data?: ErrorData): HttpError
forbiddenError
()
return
const document: {
    id: string;
    ownerId: string;
}
document
}, })
HelperStatuserror
badRequestError(message?, data?)400BAD_REQUEST
unauthorizedError(message?, data?)401UNAUTHORIZED
forbiddenError(message?, data?)403FORBIDDEN
notFoundError(message?, data?)404NOT_FOUND
conflictError(message?, data?)409CONFLICT
unprocessableError(message?, data?)422UNPROCESSABLE_ENTITY
tooManyRequestsError(message?, data?)429TOO_MANY_REQUESTS
internalError(message?, data?)500INTERNAL_ERROR

Without a message, each uses the standard reason phrase (Not Found).

Custom errors

errorResponse(status, code, message, data) builds any other error. Use stable codes: clients test them.

return errorResponse(422, 'INVALID_STATE', 'Invalid state transition', {
  state: 'archived',
  previousState: 'pending',
})
{
  "status": 422,
  "error": "INVALID_STATE",
  "message": "Invalid state transition",
  "data": { "state": "archived", "previousState": "pending" }
}

data is sent to the client: never put internal details in it.

All helpers return an HttpError, which a handler or a middleware can also throw, with the same result. isHttpError(value) recognizes one.

Errors of your services

HTTP errors belong to the routes. A service never throws an HttpError: it returns undefined when nothing is found, and throws its own error when a business rule is broken. The handler translates it:

// services/invoices.ts
export class InvoiceAlreadyPaidError extends Error {}

export const payInvoice = async (id: string) => {
  const invoice = await db.invoices.find(id)
  if (!invoice) return undefined
  if (invoice.paidAt) throw new InvoiceAlreadyPaidError(id)
  return db.invoices.update(id, { paidAt: new Date() })
}

// routes/invoices.ts
handler: async ({ req }) => {
  try {
    return (await payInvoice(req.params.id)) ?? notFoundError('Invoice not found')
  } catch (error) {
    if (error instanceof InvoiceAlreadyPaidError) return conflictError('Invoice already paid')
    throw error // anything else: 500, reported by onError
  }
}

When several routes call the same service, share the translation in a function of the feature:

const toHttpError = (error: unknown) => {
  if (error instanceof InvoiceAlreadyPaidError) return conflictError('Invoice already paid')
  if (error instanceof InvoiceCancelledError) return unprocessableError('Invoice cancelled')
  throw error
}

handler: ({ req }) => payInvoice(req.params.id).catch(toHttpError)

The service stays usable from a worker or a script, and the client only receives messages written for it. A business error left untranslated is a 500, without details: a forgotten case shows up in onError, not in the response. See the design decision.

Errors produced by Dotpress

StatuserrorWhen
400VALIDATION_FAILEDThe request does not match the schemas of the route. data.issues lists the issues.
400MALFORMED_BODYThe body cannot be parsed: invalid JSON, UTF-8 or multipart.
404NOT_FOUNDNo route matches the path (Route not found).
405METHOD_NOT_ALLOWEDThe path exists, for other methods only. The Allow header lists them.
413PAYLOAD_TOO_LARGEThe body exceeds the body limit.
415UNSUPPORTED_MEDIA_TYPECompressed body (Content-Encoding), or unknown charset.
500INTERNAL_ERRORAn unexpected error (see below).

These responses use the standard format too, and get the CORS headers: a browser client can read them.

Unexpected errors

An exception that is not an HttpError is unexpected: a bug, a database failure, a timeout. Dotpress:

  1. logs it with the request logger, so the log line carries the request ID;
  2. passes it to the onError hooks;
  3. answers 500 INTERNAL_ERROR, without any detail: an exception message can reveal a query, a path or a secret.

The response carries the X-Request-ID header: a client reporting a problem gives it, and you find the log line.

Reporting errors

onError receives every unexpected error, with the request context (undefined when the error happened outside a route):

import * as Sentry from '@sentry/node'

const app = await createApp({
  adapter,
  onError: (error, ctx) => {
    Sentry.captureException(error, { tags: { requestId: ctx?.requestId } })
  },
})

Plugins add their own hooks with api.onError(). A failing hook is logged, and never changes the response.

Details in development

exposeErrors: true adds the name, message and stack of the error to the data of 500 responses, and the issues when a response does not match its schema:

const app = await createApp({ adapter, exposeErrors: config.env === 'development' })
Danger

Never enable exposeErrors in production. Dotpress never reads NODE_ENV or any environment variable itself: the decision is yours, explicit.

Errors of native middlewares

Errors passed to next(error) by native Express middlewares are converted to the standard format: a client error (a status from 400 to 499) keeps its status, anything else is a 500 reported to onError.

Coming from v1

isDev is replaced by exposeErrors, and onException by onError. A thrown HttpError now gives the same response as a returned one (Dotpress 1 answered 500), and the 404 and 500 produced by the framework use the standard format.