3. Errors
Throwing is the error path
Section titled “Throwing is the error path”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}){ "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.
Async throws are caught too
Section titled “Async throws are caught too”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.
The error classes
Section titled “The error classes”| 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 |
Adding detail and headers
Section titled “Adding detail and headers”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' }] },}){ "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' } })Unexpected errors are treated as bugs
Section titled “Unexpected errors are treated as bugs”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')}){ "type": "about:blank", "title": "Internal Server Error", "status": 500 }Customising the output
Section titled “Customising the output”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.
Catching them in tests
Section titled “Catching them in tests”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' })Defining your own
Section titled “Defining your own”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' }, }) }}{ "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.
Or jump to the full error reference, or build something end to end in Build a todo API.