Error handling¶
BlackBull installs a default handler for every HTTP error status
plus the Exception base class, so unhandled errors always produce
a response (no naked connection drops). You can override any of
them with @app.on_error(...).
Default behaviour¶
| Condition | Default response |
|---|---|
| Path not registered | 404 |
| Method not allowed | 405 + Allow header listing valid methods |
| Unhandled exception in handler | 500 |
The default handler adapts its body to the BLACKBULL_ENV
environment variable and the request's Accept header.
development (default)¶
# BLACKBULL_ENV=development (or unset — development is the default)
For 500 errors with an exception:
Accept: text/html(browser) → styled HTML page with the full Python traceback inline.- Everything else (curl, fetch, etc.) → text/plain with the status line, exception class, exception message, and full traceback.
Goal: when you hit an unexpected 500 in dev, the failure point is visible in the response body — you don't have to dig through the access log.
For 404 / 405 / other client-error statuses without an exception,
the body is just the status code + phrase (with the Allow header
on 405).
production¶
BLACKBULL_ENV=production python myapp.py
Terse output — status code + phrase only. Exception class and message are not leaked to the network. Browsers get a minimal HTML page; everything else gets text/plain.
This is the right posture for public endpoints: production users of your service shouldn't see Python internals when something fails. Log the exception server-side via your normal logging / observability pipeline.
The same rule applies to the Content-Type and Content-Length
headers — both are set explicitly on every error response in both
environments.
Custom error handlers¶
@app.on_error(...) registers a handler for a specific status
code or exception type:
from http import HTTPStatus
from blackbull import JSONResponse
@app.on_error(HTTPStatus.NOT_FOUND)
async def handle_404(scope, receive, send):
await send(JSONResponse({'error': 'not found'},
status=HTTPStatus.NOT_FOUND))
@app.on_error(HTTPStatus.METHOD_NOT_ALLOWED)
async def handle_405(scope, receive, send):
allowed = ', '.join(scope['state'].get('allowed_methods', ()))
await send(JSONResponse({'error': f'allowed: {allowed}'},
status=HTTPStatus.METHOD_NOT_ALLOWED))
@app.on_error(ValueError)
async def handle_value_error(scope, receive, send):
exc = scope['state'].get('error_exception')
await send(JSONResponse({'error': str(exc)},
status=HTTPStatus.BAD_REQUEST))
Exception handlers use MRO walk: a handler registered for
Exception catches all unhandled subclasses. More specific
handlers (e.g. ValueError) take priority over base-class
handlers.
What's in scope['state']¶
The framework populates scope['state'] with information about
the error before calling your handler:
| Key | Type | Present when |
|---|---|---|
'error_status' |
HTTPStatus |
Always |
'error_exception' |
Exception |
Triggered by an uncaught exception |
'allowed_methods' |
tuple of str |
405 Method Not Allowed |
Reading these is how a custom handler builds a response that matches the framework's view of what failed.
Custom HTML error pages¶
To customize the look of the DEV-mode traceback page or the PROD-mode minimal page, register a handler for the status code you want to override:
@app.on_error(HTTPStatus.INTERNAL_SERVER_ERROR)
async def custom_500(scope, receive, send):
exc = scope['state'].get('error_exception')
# ... your rendering ...
await send(Response(body, status=HTTPStatus.INTERNAL_SERVER_ERROR,
content_type='text/html'))
The default handler is only used when no custom one is registered. Per-status registration overrides only that status; other errors keep the default behaviour.