Skip to content

Error Handling

Veloce turns errors into HTTP responses through a small set of pieces: the HTTPException class, the abort() shortcut for raising one, and error handlers that convert a raised exception into the response the client sees.

from veloce import Veloce, abort

app = Veloce()


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = {"1": "Ada"}.get(str(user_id))
    if user is None:
        abort(404)
    return {"user": user}

Requesting /users/2 returns a 404 with a JSON body {"detail": "Not Found"}. The rest of this page explains how that happens and how to customise it.

Raising errors with abort()

abort(status_code, detail="", headers=None) raises an HTTPException with the given status. When you omit detail, Veloce fills in the standard reason phrase for the code ("Not Found" for 404, "Forbidden" for 403).

from veloce import Veloce, abort

app = Veloce()


@app.get("/secret")
async def secret(token: str = ""):
    if token != "open-sesame":
        abort(403, "You shall not pass")
    return {"ok": True}

For known status codes abort raises a specifically-typed subclass — 403 raises Forbidden, 404 raises NotFound, and so on. This matters for handler registration: a handler registered against Forbidden will match. Unknown codes fall back to the base HTTPException.

Pass headers to attach response headers to the error — for example a Retry-After on a rate-limited response:

from veloce import Veloce, abort

app = Veloce()


@app.get("/limited")
async def limited():
    abort(429, "Slow down", headers={"Retry-After": "30"})

Raising HTTPException directly

abort() is a shorthand; you can raise HTTPException yourself when you want full control. The constructor is HTTPException(status_code=None, detail="", headers=None).

from veloce import HTTPException, Veloce

app = Veloce()


@app.get("/teapot")
async def teapot():
    raise HTTPException(418, "I refuse to brew coffee")

Veloce also ships a named subclass for each standard status code. Each carries a fixed code and description, so you can raise one without repeating the number:

from veloce import NotFound, Veloce

app = Veloce()


@app.get("/items/{name}")
async def get_item(name: str):
    if name not in {"pen", "cup"}:
        raise NotFound(f"no item named {name!r}")
    return {"item": name}

The first positional argument to a subclass is the detail message, so NotFound("no item") reads naturally while still defaulting the status code to the subclass's code.

Every name in the table below is importable from the top level:

Status code Subclass
400 BadRequest
401 Unauthorized
402 PaymentRequired
403 Forbidden
404 NotFound
405 MethodNotAllowed
406 NotAcceptable
407 ProxyAuthenticationRequired
408 RequestTimeout
409 Conflict
410 Gone
411 LengthRequired
412 PreconditionFailed
413 RequestEntityTooLarge
414 RequestURITooLong
415 UnsupportedMediaType
416 RangeNotSatisfiable
417 ExpectationFailed
418 ImATeapot
422 UnprocessableEntity
429 TooManyRequests
500 InternalServerError
501 ServerNotImplemented
502 BadGateway
503 ServiceUnavailable
504 GatewayTimeout

Why ServerNotImplemented

NotImplemented is a Python builtin, so the 501 class cannot use the obvious name. ServerNotImplemented is the exported spelling; it reads alongside InternalServerError in the 5xx block.

Added in version 0.12

These names became top-level imports in 0.12. from veloce.exceptions import NotFound still works and returns the same class.

Catching anything Veloce raised

Every exception the framework defines — HTTP errors, validation failures, WebSocket closes, routing and setup errors, JWT and signature failures — inherits VeloceError. One except clause therefore answers "did this come from Veloce?":

from veloce import BuildError, NotFound, VeloceError

for error in (NotFound("gone"), BuildError("profile", {})):
    try:
        raise error
    except VeloceError as exc:
        print(type(exc).__name__, "came from Veloce")

The root was mixed in beside the bases those classes already had, so nothing that matched before stops matching. DuplicateRouteError is still a ValueError and FilesKeyError is still a KeyError — but they are now also VeloceError, which is what lets you tell a route-registration bug apart from an ordinary bad value:

from veloce import DuplicateRouteError, VeloceError

exc = DuplicateRouteError("/items", "GET", "list_items", "list_all")
assert isinstance(exc, ValueError)     # unchanged
assert isinstance(exc, VeloceError)    # new

VeloceError also makes a catch-all handler expressible:

from veloce import JSONResponse, Request, Veloce, VeloceError

app = Veloce()


@app.exception_handler(VeloceError)
async def on_framework_error(request: Request, exc: VeloceError):
    return JSONResponse(
        {"error": type(exc).__name__, "detail": str(exc)},
        status_code=getattr(exc, "status_code", 500),
    )

VeloceError is listed first among the bases of the classes that also carry a stdlib type, so a handler registered against it wins the method-resolution walk over a broader handler registered against ValueError or KeyError.

Added in version 0.12

VeloceError is new in 0.12. Existing except clauses are unaffected — the root was added to the base list, never substituted for one.

The default error response

Without any custom handler, an HTTPException renders as JSON. The body is {"detail": <detail or description>}, the status code is exc.status_code, and any exc.headers are applied. This is what http_exception_handler produces, and it is the framework default for every error raised through abort() or HTTPException.

Registering custom error handlers

Register a handler to replace the default response for a given exception type or status code. The decorator form is @app.exception_handler(...); the imperative form is app.register_error_handler(...). A handler receives the request and the exception, and returns any value Veloce can coerce to a response (a dict, a tuple, or a response object).

from veloce import JSONResponse, NotFound, Request, Veloce

app = Veloce()


@app.exception_handler(NotFound)
async def handle_not_found(request: Request, exc: NotFound):
    return JSONResponse(
        {"error": "not_found", "path": request.path},
        status_code=404,
    )


@app.get("/missing")
async def missing():
    raise NotFound()

A handler registered against a base class catches every subclass, because Veloce walks the exception's method-resolution order to find a match. A handler on HTTPException therefore catches every NotFound, Forbidden, and so on:

from veloce import HTTPException, JSONResponse, Request, Veloce

app = Veloce()


@app.exception_handler(HTTPException)
async def handle_http_error(request: Request, exc: HTTPException):
    return JSONResponse(
        {"status": exc.status_code, "detail": exc.detail},
        status_code=exc.status_code,
    )

Registering by status code

Pass an integer instead of a class to handle a specific status code. A status-code handler takes precedence over a class handler for the same code.

from veloce import HTMLResponse, Request, Veloce

app = Veloce()


@app.exception_handler(404)
async def not_found_page(request: Request, exc):
    return HTMLResponse("<h1>Page not found</h1>", status_code=404)

The imperative form

register_error_handler and add_exception_handler register the same handlers without a decorator — useful when wiring handlers in a factory function. Both accept either an exception class or an integer status code.

from veloce import Forbidden, JSONResponse, Request, Veloce


async def on_forbidden(request: Request, exc: Forbidden):
    return JSONResponse({"error": "forbidden"}, status_code=403)


def create_app() -> Veloce:
    app = Veloce()
    app.register_error_handler(Forbidden, on_forbidden)
    return app

Note

app.exception_handler is also available under the alias app.errorhandler (one word). The two are identical.

Custom error pages

An error handler can return any response shape, so HTML error pages are just a handler that returns an HTMLResponse. Combine a status-code handler with a template for a polished 404 page:

from veloce import HTMLResponse, Request, Veloce

app = Veloce()

_PAGE = """
<!doctype html>
<title>Not found</title>
<h1>404 — {path} does not exist</h1>
<p><a href="/">Return home</a></p>
"""


@app.exception_handler(404)
async def not_found(request: Request, exc):
    return HTMLResponse(_PAGE.format(path=request.path), status_code=404)

For a content-negotiated handler, inspect the request's Accept header and return HTML or JSON accordingly:

from veloce import HTMLResponse, JSONResponse, NotFound, Request, Veloce

app = Veloce()


@app.exception_handler(NotFound)
async def not_found(request: Request, exc: NotFound):
    accept = request.headers.get("accept", "")
    if "text/html" in accept:
        return HTMLResponse("<h1>Not found</h1>", status_code=404)
    return JSONResponse({"detail": exc.detail}, status_code=404)

Validation errors

When a typed handler parameter fails to parse — a bad path converter, a missing required query value, an invalid body — the dependency resolver raises RequestValidationError, a 422 carrying a structured errors list. You do not need to register a handler to get a useful response — the default body is a structured error list, one entry per failed field with loc (where it failed), msg, and type:

{
  "detail": [
    {"loc": ["query", "limit"], "msg": "Input should be a valid integer", "type": "int_parsing"}
  ],
  "status_code": 422
}

The exported request_validation_exception_handler renders the same per-field detail list as {"detail": [...]} (without the top-level status_code field the default dispatch adds). Register it explicitly, or reshape the response with your own handler:

from veloce import JSONResponse, Request, RequestValidationError, Veloce

app = Veloce()


@app.exception_handler(RequestValidationError)
async def on_invalid(request: Request, exc: RequestValidationError):
    return JSONResponse({"errors": exc.errors}, status_code=422)

RequestValidationError subclasses ValidationError, which in turn subclasses UnprocessableEntity (a 422 HTTPException). An except ValidationError handler, or one registered against HTTPException, catches it too via the MRO walk.

The generated OpenAPI document advertises this response automatically.

Any operation whose request is validated — one carrying a path, query, header, or cookie parameter, a JSON body, or a form field — gains a 422 entry that references a shared HTTPValidationError component schema (the {"detail": [{"loc", "msg", "type"}, ...]} shape shown above).

Operations with no validatable parameter never advertise a 422, and an explicit 422 declared through responses= or openapi_extra is kept as-is.

Propagating exceptions during tests

By default Veloce catches unhandled exceptions and returns a 500. While testing it is usually better to let the original exception surface with its traceback. Set PROPAGATE_EXCEPTIONS in the config, or enable both DEBUG and TESTING (which implies propagation):

from veloce import Veloce

app = Veloce()
app.config["PROPAGATE_EXCEPTIONS"] = True

With propagation on, an exception raised in a handler re-raises out of dispatch instead of being converted to a 500. See Configuration for how PROPAGATE_EXCEPTIONS, DEBUG, and TESTING interact.

Tip

For the semantics of HTTP status codes, the MDN HTTP status reference and RFC 9110 are the authoritative sources. Veloce's named exceptions map one-to-one onto those codes.

Next steps

  • Configuration — tune PROPAGATE_EXCEPTIONS and other keys that affect how failures are reported.
  • Testing — assert on error responses with the in-memory test client.
  • Requests & Responses — the response shapes an error handler can return.
  • The API reference lists HTTPException, abort, and the registration methods with full signatures.