Skip to content

Error Handling

Stratal routes every error through a single exception pipeline. You throw a typed error, and the framework reports it (logging, external services), renders it (JSON, HTML, or a custom response), and negotiates the response format based on what the client accepts. You configure the pipeline by extending ExceptionHandler.

Three classes from stratal/errors form the hierarchy. ApplicationError is the base, HttpException extends it to carry a status code, and InternalError is what the framework wraps unknown throwables in.

The base class for all errors. It is not abstract, so you can instantiate it directly or extend it. Every instance carries a timestamp and supports the standard cause chain.

import { ApplicationError } from 'stratal/errors'
throw new ApplicationError('Something went wrong')
// Chain an underlying cause
throw new ApplicationError('Failed to load profile', originalError)

An ApplicationError resolves to HTTP 500 unless a subclass specifies otherwise. Use plain, human-readable messages.

Define your own errors by extending the class that matches the status you want.

Bake the status into the subclass so call sites stay clean:

import { HttpException } from 'stratal/errors'
export class NoteNotFoundError extends HttpException {
constructor() {
super(404, 'Note not found')
}
}
export class DuplicateEmailError extends HttpException {
constructor(email: string) {
super(409, `A user with email ${email} already exists`)
}
}

Throw them anywhere: services, controllers, guards, or middleware. The pipeline catches them at every level.

import { Transient } from 'stratal/di'
@Transient()
export class NotesService {
async findById(id: string): Promise<Note> {
const note = await this.repository.findById(id)
if (!note) {
throw new NoteNotFoundError()
}
return note
}
}

For unexpected server-side failures, extend ApplicationError and chain the underlying cause:

import { ApplicationError } from 'stratal/errors'
export class PaymentProcessingError extends ApplicationError {
constructor(reason: string, cause?: unknown) {
super(`Payment processing failed: ${reason}`, cause)
}
}

For non-HTML requests, the handler returns a JSON ErrorResponse:

{
"message": "Note not found",
"timestamp": "2026-06-11T12:00:00.000Z",
"stack": "..."
}
FieldDescription
messageHuman-readable message (translated when i18n is configured)
timestampISO 8601 timestamp captured when the error was constructed
stackStack trace, included only when ENVIRONMENT is development

For server errors (status >= 500) outside development, the handler replaces the message with a generic status text (for example Internal Server Error) before sending the response. The real message is still logged, just not exposed to the client. Errors with a 4xx status keep their actual message in every environment.

Every error flows through three phases in order:

  1. Normalize: if the throwable is already an ApplicationError, it passes through. Anything else is wrapped in an InternalError.

  2. Report: the error is logged (and optionally sent to external services). This runs in the background via waitUntil, so reporting never blocks the response.

  3. Render: the error is turned into a Response. Content negotiation decides between JSON, a custom response, or an HTML error page. Registered respond callbacks then post-process the final Response.

When you do not override the level, the handler picks log severity from the resolved status:

StatusSeverity
>= 500error
>= 400warn

Create an exception handler by extending ExceptionHandler and implementing register(). Register your reporting, rendering, and logging customizations there.

import { ExceptionHandler } from 'stratal/errors'
import { Transient } from 'stratal/di'
@Transient()
export class AppExceptionHandler extends ExceptionHandler {
register(): void {
// Report specific errors to an external service
this.reportable(PaymentError, (error, context) => {
sentry.captureException(error)
})
// Custom rendering for a specific error
this.renderable(MaintenanceError, (error, context) => {
return new Response('Service temporarily unavailable', { status: 503 })
})
// Suppress logging for expected errors
this.dontReport([NoteNotFoundError])
// Override log severity for a class
this.level(RecordNotFoundError, 'warn')
// Add global context to every error log entry
this.context(() => ({
region: this.env.CF_REGION,
deployId: this.env.DEPLOY_ID,
}))
// Post-process every error response
this.respond((response, error, context) => {
response.headers.set('X-Request-Id', crypto.randomUUID())
return response
})
// Render a custom HTML page for a given status (browser / Inertia first loads)
this.errorPage((errorResponse, status, context, error) => {
if (status === 503) {
return new Response(maintenanceHtml(), {
status,
headers: { 'content-type': 'text/html; charset=utf-8' },
})
}
})
}
}

Register it on the Stratal constructor:

export default new Stratal({
module: AppModule,
exceptionHandler: AppExceptionHandler,
})
MethodPurpose
reportable(ErrorClass, callback)Custom reporting for a class. Returns a Reportable: chain .stop() to skip default logging.
renderable(ErrorClass, callback)Custom rendering. Return a Response, an ErrorResponse, or undefined to fall through to the default renderer.
errorPage(callback)Render the HTML error page for requests that accept text/html. Callbacks run in registration order, first non-undefined wins.
dontReport([...classes])Suppress logging for the listed error classes.
level(ErrorClass, severity)Override log severity ('debug' | 'info' | 'warn' | 'error').
context(callback)Merge key-value pairs into every error log entry.
respond(callback)Transform the final Response before it is sent.
resolve(token)Resolve a dependency from the DI container inside callbacks.

When several reportable or renderable entries match the same error, the most specific subclass wins.

reportable() returns a handle whose .stop() prevents the default logger from also reporting the error:

this.reportable(ExternalApiError, (error) => {
externalLogger.log(error)
}).stop()

Every reportable, renderable, and respond callback receives an ExceptionContext, a discriminated union you narrow on context.type to learn where the error came from.

this.renderable(PaymentError, (error, context) => {
if (context.type === 'http') {
return context.ctx.json({ message: 'Payment failed' }, 500)
}
// Non-HTTP contexts: return undefined to use default rendering
})
context.typeAvailable properties
'http'ctx (the RouterContext)
'queue'queueName
'cron'(none)
'cli'commandName

This is what lets one handler serve HTTP requests, queue consumers, cron jobs, and CLI commands with the right behavior for each.

The renderer inspects the request’s Accept header:

  • text/html accepted: the handler walks the registered errorPage callbacks (first non-undefined wins) and falls back to a built-in minimal HTML page.
  • Otherwise: the handler returns the JSON ErrorResponse.

You can shape HTML rendering at three levels:

errorPage(cb)

Register dynamic, per-status HTML rendering in register().

renderDefaultHtml()

Override this method for a branded static fallback page.

wantsHtml()

Override to change how content negotiation decides on HTML.

Error messages are plain strings. When you want them translated, resolve them through the i18n service. Use withI18n() to translate inside an error class or anywhere in request-scoped code:

import { HttpException } from 'stratal/errors'
import { withI18n } from 'stratal/i18n'
export class NoteNotFoundError extends HttpException {
constructor(noteId: string) {
super(404, withI18n('notes.errors.notFound', { noteId }))
}
}

withI18n(key, params?) returns the translated string for the current request’s locale, or the key itself when called outside a request context. Validation errors are translated automatically through the i18n Zod error map.

Use the isApplicationError type guard to tell framework errors apart from unexpected throwables:

import { isApplicationError } from 'stratal/errors'
try {
await someOperation()
} catch (error) {
if (isApplicationError(error)) {
console.log('Known error:', error.message, error.timestamp)
} else {
console.error('Unexpected:', error)
}
}