exception handling
Certain errors within the Dream/Psychic ecosystem are automatically recognized by Psychic, and handled with intuitive response codes automatically for you:
castParam
In a controller, calling this.castParam with an invalid param passed will raise an exception which will cause Psychic to automatically raise a 400 error response, yielding an object with errors. In the following code, the call to castParam will raise a 400 unless the param for id is both passed, and a valid UUID:
import { OpenAPI } from '@rvoh/psychic'
import Place from '#models/Place.js'
import V1HostBaseController from './BaseController.js'
const openApiTags = ['places']
export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch a Place',
})
public async show() {
const place = await this.currentHost
.associationQuery('places')
.preloadFor('default')
.find(this.castParam('id', 'uuid'))
this.ok(place)
}
}
extractParams
Similar to castParam, extractParams will raise a 400 exception if any of the params provided matching one of the model attributes fails type validation:
export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 201,
tags: openApiTags,
description: 'Create a Place',
})
public async create() {
let place = await this.currentHost.createAssociation(
'places',
this.extractParams(Place, ['name', 'description', 'style', 'sleeps']),
)
if (place.isPersisted) place = await place.loadFor('default').execute()
this.created(place)
}
}
failable lookups
Certain methods in dream will cause a RecordNotFound exception to be thrown. If this exception is unhandled by your application code, it will automatically cause Psychic to raise a 404.
export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 204,
tags: openApiTags,
description: 'Update a Place',
})
public async update() {
const place = await this.place()
// this will raise a 404 if no record is found
await place.update(this.extractParams(Place, ['name', 'description', 'style', 'sleeps']))
this.noContent()
}
private async place() {
return await this.currentHost
.associationQuery('places')
.findOrFail(this.castParam('id', 'uuid'))
}
}
There are many failable lookup methods within dream, and taking advantage of them in your controllers can often save you an annoying manual step. Here are the other failable lookup methods that will case a 404 to be thrown:
validation failures
Setting up validations on your models will automatically cause 400 errors to be thrown if any of the validations fails.
Psychic's automatic conversions return a bare status with no body. For param, request-body, and model-validation failures that's a 400, by design: it prevents an attacker from distinguishing which validation layer rejected a request.
| Error source | HTTP status | When |
|---|---|---|
castParam fails | 400 | Invalid parameter type/value |
extractParams fails | 400 | Invalid model attributes |
| Model validation fails | 400 | isInvalid with errors |
| The database rejects a save | 400 | Not-null, check, overflow or type mismatch (DataIncompatibleWithDatabaseField) |
findOrFail / firstOrFail no match | 404 | Record not found |
save / update that writes changes to a loaded record | 404 | Row hard-deleted after it was loaded (CannotSaveMissingDream) |
Surfacing field-level validation errors
To deliberately show field-level validation errors to the end user, return a 400 that carries the error shape yourself, via this.badRequest({ errors: { ... } }). The natural source of that shape is a Dream model's .errors getter, keyed by field, each entry naming the validation that failed (presence, contains, …) or the message a custom addError supplied:
const place = Place.new(this.extractParams(Place, ['name', 'style', 'sleeps']))
if (place.isInvalid) this.badRequest({ errors: place.errors }) // { name: ['presence'], ... }
await place.save()
Without the explicit check, an invalid save() / create() still returns 400, but with no body; the errors are logged only under NODE_DEBUG=psychic. Conveying the error shape to the client is an explicit, deliberate act. To document that body in the OpenAPI spec, reshape the shared BadRequest response once — see Customizing default error responses.
409 from a database constraint
Some invariants live only in the database — a unique index, or an ON DELETE action that blocks a delete. Converting one into a status code is the narrow, specific catch that's allowed: match on pgErrorType, which maps a pg.DatabaseError to a string literal, rather than on message text.
import { pgErrorType } from '@rvoh/dream/errors'
try {
await city.destroy()
} catch (error) {
switch (pgErrorType(error)) {
case 'RESTRICT_VIOLATION':
case 'FOREIGN_KEY_VIOLATION':
return this.conflict() // Places still reference this City
default:
throw error
}
}
this.noContent()
A delete guard must accept both literals. A delete blocked by .onDelete('restrict') arrives as 'RESTRICT_VIOLATION' or 'FOREIGN_KEY_VIOLATION' depending on the Postgres version, and no action raises 'FOREIGN_KEY_VIOLATION'. Since restrict is the recommended foreign key action, a guard written for one literal can miss and 500 — and only a spec that creates a referencing child row first reaches the branch. 'UNIQUE_VIOLATION' on create follows the same pattern.
Debugging unexpected 400s (and OpenAPI-triggered 500s in specs)
When an endpoint returns an unexpected 400 (or a spec fails with a 500 thrown by OpenAPI response validation) and you can't tell whether OpenAPI validation or controller logic is the cause, temporarily disable validation to isolate it:
@OpenAPI(Place, {
status: 200,
validate: { all: false }, // TEMPORARY — remove once debugged
})
validate accepts requestBody, responseBody, headers, query, and all booleans. Setting all: false disables every validation segment:
- For a 400 on request, if the failure stops, the problem was in the payload — log
this.params(console.dir(this.params, { depth: null })) and compare it against what the endpoint expects. - For a 500 in a spec from response validation, disabling validation lets the real response body reach the test so you can inspect what actually came back, instead of an opaque validation error message. If the decorator sets
fastJsonStringify: true, comment that out too: it serializes through the response schema, coercing the very mismatch you're hunting (nullrenders as""or0). - If the failure persists with validation off, OpenAPI validation wasn't the cause. A persistent 400 is a
castParam/extractParamsrejection, a model validation failing on save, or a not-null, check-constraint, overflow or type-mismatch error from the database on save; a persistent 500 is an unhandled error, a unique-constraint violation among them.
Remove the validate line (and restore fastJsonStringify) once the problem is identified — leaving it disabled defeats the protection OpenAPI validation provides. NODE_DEBUG=psychic also surfaces validation info, but disabling validation is usually more useful because it lets the real response through for direct inspection.
try/catch in controllers
Because Psychic converts common errors to appropriate HTTP responses automatically, adding try/catch to controller actions is usually wrong and actively harmful:
- If the caller is an HTTP handler, catching the error causes the user to receive a
200instead of the correct error status. - An unhandled exception with a stack trace is far more useful for diagnosis than a program that silently swallows the error and continues.
Only catch a specific, expected error — and re-throw everything else. Never wrap large blocks of controller code in a catch-all try/catch. Logging the error and swallowing it is not equivalent to letting it propagate: the log is for humans reading after the fact; the HTTP client still gets a wrong status code.
// BAD — swallows all errors, user gets 200 on failure
public async show() {
try {
const place = await this.place()
this.ok(place)
} catch (e) {
PsychicApp.logWithLevel('error', 'failed to show place', { error: e })
}
}
// GOOD — let Psychic convert the RecordNotFound to a 404 automatically
public async show() {
const place = await this.place()
this.ok(place)
}
The bad example also bypasses normal control flow: logging does not make the HTTP response fail, and swallowing the error prevents Psychic from returning the correct status code.
Reacting to unhandled server errors
Server errors fire the server:error hook: this.internalServerError(), and anything thrown that Psychic doesn't map to a status — another library's error that carries a status included. Psychic's other error helpers (this.notFound(), this.serviceUnavailable(), …) and Koa's ctx.throw with a 4xx or 501–510 status render their own status without it, whether thrown from a controller action or from psy.use middleware. This is the place to ship failures to Sentry, Datadog, structured alerting, or whatever your error pipeline is:
psy.on('server:error', (err, ctx) => {
PsychicApp.logWithLevel('error', 'unhandled server error', {
err,
method: ctx.method,
path: ctx.path,
})
// ...ship to Sentry / Datadog / etc.
if (!ctx.headerSent) ctx.status = 500
else if (AppEnv.isDevelopmentOrTest) throw err
})
The handler signature is (err: Error, ctx: Koa.Context) => void | Promise<void>. Before the hooks run, Psychic logs the error and sets a 500 with an empty body; a hook may reshape that response through ctx, and one that sets nothing leaves it. The scaffold's hook re-throws in development/test when headers were already sent, so a late failure surfaces loudly.
server:error is the surface for every shapeable server error — one the boundary catches while the response can still be formed. Psychic mounts an error boundary as its outermost middleware, so the body parser, CORS origin callbacks, your own psy.use(...) middleware, anything mounted via server:init:after-routes (e.g. Bull Board), and controller actions all escalate their uncaught errors to this one hook. A deliberate HTTP error (above) and the body parser's own 4xx errors (malformed JSON, an oversized or undecompressable body) never reach it: those render as their own status. Another library's error does, even one carrying a 4xx status (an API client mirroring an upstream 401), and it is answered 500 — so don't branch on err.status inside the hook.
One registration does not, however, give you every 5xx. A residual class bypasses server:error and surfaces only on Koa's app-level 'error' event: an error a middleware throws after headers were already sent, and a response-stream failure. Psychic's own koaApp.on('error') listener logs this class through the configured logger but does not run your server:error hooks for it. If your error-tracking pipeline — not just your logs — must capture those post-response failures too, register your own app-level listener from a server:init hook:
psy.on('server:init:after-middleware', psychicServer => {
psychicServer.koaApp.on('error', err => {
// ship err to Sentry / Datadog / etc.
})
})
This won't double-report the shapeable errors — those are handled inside the boundary and never reach this event.
An error-tracking SDK configured with defaultIntegrations: false (common, to keep the captured payload lean) does not auto-attach HTTP request context. The server:error hook receives ctx, so pull the fields you want — ctx.method, ctx.path, the authenticated principal — and attach them to the event yourself, as the example above logs them.
Logging
Use PsychicApp.log / PsychicApp.logWithLevel for all application logging rather than console.log. They write through the logger conf/app.ts sets with psy.set('logger', ...) (Winston in the generated app), so output respects its level and transports:
PsychicApp.log('booking confirmed', { bookingId: booking.id, nights })
PsychicApp.logWithLevel('warn', 'place nearly booked out', { placeId: place.id, openNights })
Both are variadic and join their arguments into one message string — an object is rendered with util.inspect, not passed to the logger as structured fields. logWithLevel accepts 'debug' | 'info' | 'warn' | 'error'.
The generated request logger (conf/system/requestLogger.ts) is separate: it writes one structured JSON line per request through its own Winston instance. See the Security Overview — Logging & error disclosure for the request-logger redaction knobs (headerBlocklist, bodyBlocklist, ignoredRoutes).