The basics
Error handling
When a request ends in an error, Oriel logs it if it should and answers with an error page or JSON.
withExceptions() changes both.
Introduction#
A new app handles errors with no setup: an unexpected error that a route or a middleware throws is reported to your
log, and the user gets a response with the error's status. To change how errors are reported and rendered, give
withExceptions() in bootstrap/ a function that receives the configurator, as the rest of this page shows.
Configuration#
debug in config/ decides how much an error response shows. It reads APP_DEBUG from your .env (see Debug
mode), and is false unless the variable says otherwise:
import { env } from '@orieljs/framework'export default { name: env('APP_NAME', 'Oriel'), env: env('APP_ENV', 'production'), debug: env('APP_DEBUG', false), url: env('APP_URL', 'http://localhost'), timezone: 'UTC', locale: env('APP_LOCALE', 'en'), cipher: 'AES-256-GCM', key: env('APP_KEY'), previous_keys: String(env('APP_PREVIOUS_KEYS', '')).split(',').filter(Boolean),}A new app's .env sets APP_DEBUG=true, for your machine. With it, a server error shows the debug page: the error's
class and message, the request's method and URL, the lines of your code around where it was thrown, the stack, and
the errors that caused it. A request that expects JSON gets the message, the error's name, the file, the line and
the stack. Errors with an HTTP status, such as a 404, get their usual page either way.
Handling exceptions#
Reporting exceptions#
Reporting an error writes it to your log, at the error level, with the error itself as the entry's
exception context. report() registers a function that receives each error to report, to send it somewhere else
too, such as an error tracking service:
import { dirname } from 'node:path'import { Application } from '@orieljs/framework'import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'import providers from './providers'export function createApplication(): Promise<Application> { return Application.configure({ basePath: dirname(import.meta.dirname) }) .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' }) .withExceptions((exceptions) => { exceptions.report((error) => { if (error instanceof InvalidOrderException) { // ... } }) }) .withProviders(providers) .create()}The error is still logged afterwards. Return false from the function to stop that, and the functions registered
after it:
exceptions.report((error) => { if (error instanceof InvalidOrderException) { // ... return false }})Each error is reported once, however many times it reaches the handler, as in Laravel after dontReportDuplicates().
Laravel's global and per-exception log context, context(), isn't in Oriel; Logging shows how to add
context to every log entry.
Reporting an error yourself#
Oriel has no report() helper. To report an error and carry on with the request, call the exception handler's
report():
async function isValid(value: string): Promise<boolean> { try { validateTheValue(value) return true } catch (error) { await app().make(ExceptionHandler).report(error) return false }}Exception log levels#
Every report is logged at the error level. Laravel's level(), which changes the level by the error's class, isn't
in Oriel: log the error yourself from a report() function, at the level you want, and return false.
Ignoring exceptions by type#
dontReport() takes the error classes never to report. They are still rendered as usual:
exceptions.dontReport(InvalidOrderException)Oriel never reports the errors that are part of normal requests: an abort(), a missing route or model, a failed
validation, an unauthenticated or unauthorized user, and a CSRF token mismatch. Laravel's dontReportWhen(),
stopIgnoring() and ShouldntReport interface aren't in Oriel.
Rendering exceptions#
render() registers a function that turns an error into a response. It receives the error and the request, and
returns a Response, or nothing to leave the error to the default rendering:
import { dirname } from 'node:path'import { Application, response, View } from '@orieljs/framework'import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'import providers from './providers'export function createApplication(): Promise<Application> { return Application.configure({ basePath: dirname(import.meta.dirname) }) .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' }) .withExceptions((exceptions) => { exceptions.render(async (error) => { if (error instanceof InvalidOrderException) { return response(await View.make('errors.invalid-order').render(), 500) } }) }) .withProviders(providers) .create()}The functions run first, for every error, so they can change how a built-in error renders too:
exceptions.render((error, request) => { if (error instanceof ModelNotFoundError && (request as HttpRequest).is('api/*')) { return response().json({ message: 'Record not found.' }, 404) }})The request is typed unknown: during a request it is the request request() returns.
Rendering exceptions as JSON#
An error is rendered as JSON when the request expects JSON: its Accept header asks for JSON first, or it is an
AJAX request that accepts anything (see content negotiation). Otherwise it gets
an HTML page. With shouldRenderJsonWhen(), you decide it yourself; the API starter kit answers JSON for every route
under /api:
exceptions.shouldRenderJsonWhen((request) => request.is('api/*') || request.expectsJson())Laravel's respond(), which changes every response the handler renders, isn't in Oriel: use render().
Renderable exceptions#
An error class of your own can render itself. Give it a render() method that returns a Response:
import { response } from '@orieljs/framework'export class InvalidOrderException extends Error { /** Render the exception as an HTTP response. */ render(request: unknown): Response { return response('The order could not be processed.', 422) }}When render() returns something that isn't a Response, the error is rendered as usual. A toResponse() method
works the same way. The error is still reported, unless its class is in dontReport(). Laravel's reportable
exceptions, with a report() method of their own, aren't in Oriel: use a report() function in withExceptions().
Laravel's throttle(), which samples or rate limits the reports, isn't in Oriel either.
HTTP exceptions#
abort() ends the request with an HTTP status, from anywhere in your code:
abort(404)A message and headers can follow. The message is what JSON responses show; the HTML pages show it for a 403 only:
abort(403, 'You may not edit this post.')abort(503, 'Down for maintenance', { 'Retry-After': '120' })abortIf() and abortUnless() abort when a condition is true, or false:
abortIf(user === undefined, 403)abortUnless(post.published_at !== null, 404)abort() also takes a ready Response, which is sent as it is: abort(response('Gone fishing', 418)).
Statuses by error#
Oriel's own errors carry their statuses. Each gets the same page, or JSON, as an abort() with that status:
| Error | Status |
|---|---|
| No route matches the URL | 404 |
| The route doesn't take the method | 405, with an Allow header |
A model not found, by findOrFail() or route model binding | 404 |
A guest on a route with the auth middleware | 401 as JSON; otherwise a redirect to the login page |
| An authorization check that fails | 403, or the status the policy chose |
| A form without its CSRF token | 419 |
| A failed validation | 422 as JSON; otherwise a redirect back with the errors |
Too many requests on a throttle route | 429, with Retry-After and the X-RateLimit-* headers |
| A body over the size limit | 413 |
| A JSON body nested too deeply | 400 |
| Anything else | 500 |
None of these is reported, except the last. The login page a guest is sent to is http.redirect_guests_to, set with
redirectGuestsTo(), else the route named login, else
/login; the URL the guest wanted is kept for after they sign in.
Custom HTTP error pages#
To show your own page for a status, add a view named after it: resources/ for a 404. The view
receives the status, the message and the exception:
interface Props { status: number message: string exception: unknown}export default function NotFound({ status, message, exception }: Props) { return ( <html lang="en"> <body> <h1>{status}</h1> <h2>{exception instanceof Error && exception.message !== '' ? exception.message : message}</h2> </body> </html> )}message is the status's text, "Not Found", except for a 403, where it is the error's message. The error's own
message is on exception, and is empty for an abort(404) without one. A new app has no error views. Without one,
the page is a minimal one with the status and its text, as 404 | Not Found.
The pages are views like any other, so they can use your layout. A view that fails to render is reported, and the minimal page is sent in its place.
Fallback HTTP error pages#
resources/ is the page for any client error without a page of its own, and 5xx.tsx for any
server error.
What a production error shows#
With debug off, a server error shows nothing of the error: your errors/ or errors/ view, or the
minimal "500 | Server Error", and { "message": "Server Error" } to a request that expects JSON. The error is
reported to your log with its stack.
An error with an HTTP status shows its status text on the HTML page (a 403 shows its message), and its message in
JSON: { "message": "No query results for model [Post] 5" } for a missing Post. Keep secrets out of the messages
you give abort().