Request & Response

The immutable Request and chainable Response API

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Every handler receives aRequestand returns a value that Chirp turns into a response.Requestis a frozen, read-only snapshot of the incoming HTTP request -- method, path, headers, cookies, query string, and an async-readable body. When you need to control the raw response (status, headers, cookies, redirects), you build aResponse through chainable .with_*()transformations; each call returns a new immutable copy.

Most handlers never construct aResponsedirectly. They return a return type likeTemplateor Fragment and let Chirp negotiate the response. Reach for Requestand Responsewhen you need the raw HTTP details.

Request

Requestis a frozen dataclass -- a snapshot of what arrived. The data does not change under you mid-handler, and the object is honest about that.

@app.route("/search")
async def search(request: Request):
    q = request.query.get("q", "")
    lang = request.headers.get("Accept-Language", "en")
    session = request.cookies.get("session_id")
    return Template("search.html", q=q)

Properties

Property Type Description
method str HTTP method (GET, POST, etc.)
path str Request path (/search)
query QueryParams Query string parameters
headers Headers Immutable request headers
cookies dict[str, str] Parsed cookies
content_type str | None Content-Type header value

Async body access

The body is read asynchronously -- it may not have arrived yet. Pick the access method that matches your content type; they all read the same underlying body.

async def upload(request: Request):
    body_bytes = await request.body()
async def note(request: Request):
    text = await request.text()
async def api(request: Request):
    data = await request.json()
async def submit(request: Request):
    form = await request.form()
async def ingest(request: Request):
    async for chunk in request.stream():
        process(chunk)

htmx detection

When a request comes from htmx, you can branch on it. The richest surface is the typedrequest.htmx namespace (HtmxDetails), which is truthy when theHX-Requestheader is present:

@app.route("/results")
def results(request: Request):
    if request.htmx:                 # any htmx request
        target = request.htmx.target  # HX-Target value (e.g. "#results")
        trigger = request.htmx.trigger
    ...

For most handlers you do not need to branch by hand at all -- return a Page and Chirp negotiates a fragment for htmx and a full page for browsers. Branch manually only when the two paths truly differ.

Two convenience properties cover the common questions:

Property True when
request.is_htmx Any htmx request (HX-Requestpresent)
request.is_narrow_fragment A narrow swap -- excludes boosted navigations and history restores, which still need full page content
Deprecated since request.is_fragment

QueryParams and Headers

BothQueryParams and Headers implement the MultiValueMappingprotocol -- the same key can carry multiple values:

# First value
q = request.query.get("q", "")

# All values for a key
tags = request.query.get_list("tag")   # ["python", "web"]

# Typed coercion (None if missing or unparseable)
page = request.query.get_int("page", 1)
debug = request.query.get_bool("debug", False)

# Existence
if "q" in request.query:
    ...

Response

Responses are built through chainable transformations. Each.with_*()returns a newResponse:

return (
    Response("Created")
    .with_status(201)
    .with_header("Location", "/users/42")
    .with_cookie("session", token)
)

Chainable methods

Method Description
.with_status(code) Set status code
.with_header(name, value) Add a header
.with_headers(dict) Add multiple headers
.with_content_type(type) Set Content-Type
.with_cookie(name, value, **opts) Set a cookie
.without_cookie(name) Delete a cookie

.with_header() and .with_headers()append -- they do not replace an existing header of the same name.

htmx response headers

Chirp provides htmx-specific response methods:

return (
    Response("OK")
    .with_hx_location("/new-page")                  # HX-Location
    .with_hx_trigger("item-added")                  # HX-Trigger
    .with_hx_trigger_after_settle("refresh-count")  # HX-Trigger-After-Settle
)

Redirects

Redirect is the plain HTTP redirect, defaulting to status 302:

from chirp import Redirect

@app.route("/old")
def old():
    return Redirect("/new")

For a redirect that must work for both htmx and plain requests, use hx_redirect() (status 303by default):

from chirp import hx_redirect

@app.route("/items/{id:int}/archive", methods=["POST"])
def archive_item(id: int):
    archive(id)
    return hx_redirect("/items")