Skip to content

3. Errors

There is no next(err). Throw, and Oven turns it into the right response.

import { NotFound } from '@theoven/core'
app.get('/users/:id', (ctx) => {
const user = users.get(ctx.params.id)
if (!user) throw new NotFound(`No user with id ${ctx.params.id}`)
return user
})
404 response
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No user with id 99"
}

That is RFC 9457 application/problem+json — the standard shape for HTTP error bodies, so clients and gateways already understand it.

app.get('/report', async () => {
await Bun.sleep(10)
throw new BadRequest('Date range is too wide') // caught, becomes 400
})

Express needed express-async-errors for this, or every async handler needed its own try/catch. In Oven it is simply how errors work.

Class Status
BadRequest 400
Unauthorized 401
Forbidden 403
NotFound 404
MethodNotAllowed 405
Conflict 409
PayloadTooLarge 413
UnsupportedMediaType 415
UnprocessableContent 422
TooManyRequests 429
InternalServerError 500
ServiceUnavailable 503

detail is a bag of extra members merged into the problem document, not the RFC’s detail string — that one comes from the message. This is exactly how validation reports fields:

throw new UnprocessableContent('Validation failed', {
detail: { errors: [{ path: 'email', message: 'Invalid email address' }] },
})
422 response
{
"type": "about:blank",
"title": "Unprocessable Content",
"status": 422,
"detail": "Validation failed",
"errors": [{ "path": "email", "message": "Invalid email address" }]
}

Note where errors landed: at the top level, beside status. RFC 9457 allows extension members there, and putting them one level down would mean every client digging through a wrapper.

Headers travel with the error, which is how a 429 carries Retry-After:

throw new TooManyRequests('Slow down', { headers: { 'retry-after': '30' } })

Anything thrown that is not an OvenError becomes a 500. In development you see the real message. In production you do not:

app.get('/boom', () => {
throw new Error('postgres://user:hunter2@db-primary/app')
})
production response
{ "type": "about:blank", "title": "Internal Server Error", "status": 500 }
const app = createApp({
onError: (error, ctx) => {
ctx.log.warn('request failed', { status: error.status })
return { ok: false, code: error.status }
},
})

Return undefined to fall back to the default rendering. If your handler itself throws, Oven logs that and still renders the original error — a broken error handler must not hide the problem it was meant to describe.

An OvenError is an ordinary error until it reaches the response, so a unit test can assert on it directly:

expect(() => parseRange('2020-2050')).toThrow(BadRequest)
const response = await app.fetch(new Request('http://x/users/99'))
expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({ status: 404, title: 'Not Found' })

Subclass OvenError. It takes a status, a title, an optional message and an options object:

import { OvenError } from '@theoven/core'
export class PaymentRequired extends OvenError {
constructor(message = 'Payment required.') {
super(402, 'Payment Required', message, {
// A resolvable URI documenting this specific failure. RFC 9457's `type` is the field
// clients are meant to branch on — unlike a status, it can be specific to your API.
type: 'https://acme.dev/problems/payment-required',
detail: { upgradeUrl: '/billing' },
})
}
}
402 response
{
"type": "https://acme.dev/problems/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "Payment required.",
"upgradeUrl": "/billing"
}

The built-in classes are made the same way. Unauthorized also carries a default WWW-Authenticate: Bearer header, because RFC 9110 says a 401 must say how to authenticate and leaving that to each throw site means most of them forget.

Database →

Or jump to the full error reference, or build something end to end in Build a todo API.