Error Handling
Define a custom response once when a domain exception needs an HTTP shape of its own:
.withExceptions((exceptions) { exceptions.render<DomainException>((error, request) { return Response.json( {'message': error.message, 'code': error.code}, status: 409, ); });})Every matching exception now receives the same response, whether it came from a route, middleware, or a service deeper in the application.
Every exception that escapes a route becomes a response. You do not write try/catch in your handlers to produce error responses — you throw, and the handler renders.
How Exceptions Are Rendered
| Thrown | Status | Body | Reported? |
|---|---|---|---|
HttpResponseException |
its own response | its own response | no |
ValidationException |
422 | {message, errors} |
no |
HttpException and subclasses |
its own status, with its headers | {message} |
no |
| anything else | 500 | {message: "Server Error"} |
yes |
The format follows the request: JSON when the client asked for JSON
(request.wantsJson), otherwise a minimal HTML page. Paths beginning with
/api also want JSON, so the same abort(404) serves an API client and a
browser correctly.
Debug Mode
With app.debug enabled, a 500 carries the detail you need:
{ "message": "Bad state: no connection", "exception": "StateError", "trace": ["#0 fetch (package:blog/repo.dart:12:5)", "#1 ..."]}In debug mode message carries the real exception message rather than Server Error, exception names the type, and trace is the stack trace split into one entry per line — which reads far better than an escaped blob when you are looking at it in a terminal or a browser’s network tab.
With it disabled, the same failure returns only {"message": "Server Error"}. The stack trace never reaches the client in production. The flag is read at render time, so it reflects your configuration rather than whatever was true when the handler was constructed.
Throwing HTTP Errors
abort(404);abort(403, 'You do not own this post.');abort(429, 'Slow down.', {'retry-after': '30'});Or the typed forms:
throw NotFoundHttpException();throw UnauthorizedHttpException('Token expired.');throw ForbiddenHttpException();throw MethodNotAllowedHttpException(['GET', 'POST']);throw TooManyRequestsHttpException();MethodNotAllowedHttpException sets the allow header for you. Omit a message and the standard reason phrase for the status is used.
Returning a Specific Response
To short-circuit with a response you built yourself, from anywhere in the call stack:
throw HttpResponseException(Response.json({'reason': 'locked'}, status: 423));It is returned exactly as given and is never logged as an error.
Customising the Handler
Configure the handler in bootstrap/app.dart:
.withExceptions((exceptions) { exceptions.render<PaymentFailed>((error, request) => Response.json({'message': 'Payment failed', 'code': error.code}, status: 402));
exceptions.report((error, stackTrace) => Sentry.capture(error, stackTrace));
exceptions.dontReport<CacheMiss>();})| Method | Description |
|---|---|
render<T>(fn) |
Render exceptions of type T yourself. Return null to fall through to the default. |
report(fn) |
Add a reporter. Every reportable exception is passed to each. |
dontReport<T>() |
Stop reporting exceptions of type T. |
Reporting
By default every unhandled exception is written through Log.error, with its stack trace. HttpException and ValidationException are not reported — a 404 or a failed validation is an ordinary outcome, not an incident, and logging them buries the failures that matter.
Reporters you add run alongside the default one, so wiring an error tracker does not stop the log.
exceptions.report((error, stackTrace) { errorTracker.send(error, stackTrace);});Errors and Middleware
Exceptions are converted to responses inside the middleware pipeline, so outer middleware still run on the way out. A 500 still receives its CORS headers, which is the difference between seeing the real error in a browser and seeing a misleading CORS failure. See Middleware.