Skip to content

Advanced responses

By default a handler that returns a dict or model is serialised to a JSONResponse, with status 200 and the headers Veloce computes for you.

This page covers the levers for taking that control back:

  • picking a different Response class
  • documenting extra status codes in the OpenAPI schema
  • setting cookies and headers with typed helpers
  • changing the status code from inside the handler

For the basics of returning a response, see Requests and responses.

Choosing a response class

Pass response_class= to a route to control how the handler's return value is encoded. The class is called with the return value, so a dict returned from this handler is rendered as HTML-bytes by HTMLResponse rather than JSON:

app.py
from veloce import HTMLResponse, Veloce

app = Veloce()


@app.get("/page", response_class=HTMLResponse)
async def page():
    return "<h1>Hello</h1>"

The default is JSONResponse. Returning a Response instance from the handler always wins over response_class — the instance is sent as-is.

A default class for the whole app

Set default_response_class= on the app (or a Blueprint) to change the fallback for every route that does not declare its own. A route-level response_class= still overrides it:

app.py
from veloce import ORJSONResponse, Veloce

app = Veloce(default_response_class=ORJSONResponse)


@app.get("/items")
async def items():
    return [{"id": 1}, {"id": 2}]

Note

ORJSONResponse is a semantic alias for JSONResponse (both encode with orjson). Declaring it communicates the encoder choice; it does not change the output bytes.

A custom JSON content type

Subclass JSONResponse and override default_media_type to ship a JSON suffix type such as application/problem+json without re-implementing the encoder:

from veloce import JSONResponse


class ProblemJSON(JSONResponse):
    default_media_type = "application/problem+json"

Use it as @app.get("/x", response_class=ProblemJSON). The class is also honoured by ProblemJSON.from_bytes(...) when you already hold encoded JSON.

Documenting additional responses

A route advertises one success response in its OpenAPI document. Pass responses= to document the other status codes the operation can return — a {status: spec} mapping where each spec may carry a model (a Pydantic model for the response body schema), a description, and any free-form OpenAPI keys (headers, links):

app.py
from pydantic import BaseModel

from veloce import Veloce, status

app = Veloce()


class Item(BaseModel):
    id: int
    name: str


class Error(BaseModel):
    detail: str


@app.get(
    "/items/{item_id}",
    responses={
        status.HTTP_404_NOT_FOUND: {"model": Error, "description": "Item not found"},
    },
)
async def get_item(item_id: int) -> Item:
    return Item(id=item_id, name="Widget")

The 404 now appears in the generated schema alongside the 200. The model schema is emitted under application/json only — see the warning below.

responses= model schemas are JSON-only

A model entry generates its body schema under application/json. To document a non-JSON media type, supply the OpenAPI content object yourself as a free-form key instead of model:

responses={
    200: {
        "description": "A CSV export",
        "content": {"text/csv": {"schema": {"type": "string"}}},
    },
}

Note

Set responses= on the app or a Blueprint to overlay the same entries onto every route; a route's own responses= merges on top, and per-route status codes win.

responses= only shapes the schema. It does not make the handler return those statuses — that is the job of status_code=, HTTPException, or returning a Response directly.

Status codes

Set the default success status with status_code=. The named constants in status read better than bare integers:

app.py
from veloce import Veloce, status

app = Veloce()


@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item():
    return {"id": 1}

The handler returns a plain dict and the response goes out as 201 Created.

Returning a different status per request

When the status depends on the result, return a response object with the status you want. It overrides the route's status_code=:

app.py
from veloce import JSONResponse, Veloce, status

app = Veloce()

_items: dict[int, str] = {}


@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str):
    created = item_id not in _items
    _items[item_id] = name
    code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
    return JSONResponse({"id": item_id, "name": name}, status_code=code)

Dynamic status from an injected Response

Returning a JSONResponse bypasses the route's response_model filtering and response_class. When you want the normal serialisation but still need to set the status (or a header or cookie) conditionally, declare a response: Response parameter. Veloce injects a fresh response object whose status and headers are merged onto the final one:

app.py
from veloce import Response, Veloce, status

app = Veloce()

_items: dict[int, str] = {}


@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str, response: Response):
    if item_id not in _items:
        response.status_code = status.HTTP_201_CREATED
    _items[item_id] = name
    return {"id": item_id, "name": name}

The returned dict is still serialised through the route's normal path; only the status you set is applied.

Note

The injected Response is created once per request and shared with any dependency that also declares the parameter. Until you assign response.status_code, it carries the sentinel 0, meaning "not set" — the dispatcher leaves the real status untouched.

Headers

Set a one-off header through the injected response's headers dict, or use the typed setters and properties on Response for the standard fields:

app.py
from veloce import Response, Veloce

app = Veloce()


@app.get("/report")
async def report(response: Response):
    response.headers["X-Report-Id"] = "42"
    response.set_cache_control(max_age=3600, public=True)
    return {"ok": True}

The typed helpers build correct values for you instead of hand-formatting header strings:

Helper Sets Notes
set_cache_control(...) Cache-Control Combines directives in RFC 9111 order.
add_vary(*names) Vary Merges and de-duplicates case-insensitively.
set_etag(value, weak=False) ETag Quotes the value; add_etag() derives one from the body.
set_content_disposition(...) Content-Disposition Builds the RFC 6266 attachment/inline value.
last_modified = ... Last-Modified Accepts datetime, Unix timestamp, or string.
retry_after = ... Retry-After Accepts int, timedelta, or datetime.

Conditional responses and Vary

add_vary tells caches which request headers the response depends on, so a Vary: Cookie response is not served to a different user. make_conditional downgrades a response to 304 Not Modified when the request's If-None-Match or If-Modified-Since preconditions already match (RFC 9110 §13):

app.py
from veloce import Response, Veloce

app = Veloce()


@app.get("/profile")
async def profile(request):
    response = Response(body=b"<p>hi</p>", content_type="text/html")
    response.add_vary("Cookie")
    response.add_etag()
    return response.make_conditional(request)

Cookies

Set cookies with set_cookie on the injected response. The validated parameters build a correct RFC 6265 Set-Cookie header, and multiple calls append rather than overwrite:

app.py
from veloce import Response, Veloce

app = Veloce()


@app.post("/login")
async def login(response: Response):
    response.set_cookie("session", "abc123", httponly=True, samesite="Lax")
    return {"ok": True}

samesite defaults to "Lax". Pass samesite="None" (with secure=True) for a cross-site cookie, or samesite=None to omit the attribute entirely.

Match the attributes when deleting

A browser only replaces an existing cookie when Path, Domain, and the Secure / SameSite / Partitioned attributes match. Pass the same flags to delete_cookie that you used to set it, or the cookie is stored twice instead of removed.

Partitioned cookies

Pass partitioned=True for a CHIPS cookie (Cookies Having Independent Partitioned State), keyed to the top-level site so embedded third-party contexts each get an isolated jar. Partitioned requires Secure, so it is only emitted when secure=True:

response.set_cookie(
    "tracker", "xyz", secure=True, samesite="None", partitioned=True
)

__Host- and __Secure- prefixes

Pass prefix="host" or prefix="secure" to add the RFC 6265bis §4.1.3 name prefix and enforce its invariants. "secure" requires secure=True; "host" also requires path="/" and no domain. A violation raises ValueError:

response.set_cookie("session", "abc123", secure=True, prefix="host")

The cookie travels on the wire as __Host-session.

Testing responses

Use the in-memory TestClient to assert the status, headers, and cookies without a server:

from veloce import Response, TestClient, Veloce, status

app = Veloce()

_items: dict[int, str] = {}


@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str, response: Response):
    if item_id not in _items:
        response.status_code = status.HTTP_201_CREATED
        response.set_cookie("seen", "1")
    _items[item_id] = name
    return {"id": item_id, "name": name}


client = TestClient(app)

resp = client.put("/items/1?name=Widget")
assert resp.status_code == 201
assert resp.cookies["seen"] == "1"

resp = client.put("/items/1?name=Gadget")
assert resp.status_code == 200

Next steps