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       # raw: "#results" or "div#results"
        target_id = request.htmx.target_id # canonical: "results"
        source_id = request.htmx.source_id # htmx 4 HX-Source id
        trigger = request.htmx.trigger     # htmx 2 id, or source_id fallback
    ...

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, history restores, and htmx 4 body-levelfullrequests
request.htmx.request_type Valid htmx 4HX-Request-Type ("full" / "partial"), otherwise None
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)
)

with_hx_trigger_after_settle() and with_hx_trigger_after_swap()are htmx 2 and generic wire helpers. Htmx 4 removed both response headers. When the exact preview tier serves an htmx request containing either header, Chirp rejects the response before send with the unsupported header, helper, selected version, and lifecycle migration.

For htmx 4, render the event payload into the same target block as escaped data-*attributes. External application JavaScript reads that marker from the target'shtmx:before:settle(old after-swap intent) or htmx:after:settle(old after-settle intent) event. Do not map either header to receipt-phaseHX-Trigger or request-level htmx:after:swap; both change the timing contract.

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")  # htmx 2/generic only
)

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")