Migrating to v9¶
Responder 9.0 makes the v8.1 deprecation path the default behavior. Most apps only need small cleanup: use the supported test-client helpers, pass explicit route endpoints, and rely on precision-preserving JSON defaults.
The compatibility switches documented below are explicit and quiet. They are there for apps that need to keep 8.x behavior while migrating.
Run your test suite with warnings surfaced to find any unrelated deprecations:
python -W error::DeprecationWarning -m pytest
Multipart uploads stream to disk¶
Multipart bodies now parse incrementally as they arrive: file parts spool to
temporary files (rolling to disk past ~1 MB) instead of being buffered in
RAM. Nothing changes in how you read uploads — File() markers,
req.media("files"), and req.media("form") all work as before, and
UploadFile.save() copies disk-to-disk in chunks.
Two consequences of the body no longer being buffered:
The raw body is consumed by the parse.
await req.contentafter a multipart parse raises aRuntimeError(previously it returned the full buffered body). If a handler needs both the raw bytes and the parsed form, awaitreq.contentfirst — parsing then falls back to the buffered body, exactly like 8.x. Handlers usingFile()/Form()markers have the form parsed before they run, so the raw multipart body is not available to them at all (this matches Starlette and FastAPI).Text fields share the streaming parser’s limits.
req.media("form")on a multipart body now goes through the same parse asmedia("files"): at most 1000 parts and 1 MB per text field (oversized or malformed bodies get a400). Text fields with invalid UTF-8 are now decoded lossily instead of being silently dropped.
Request bodies are capped at 100 MiB by default¶
max_request_size now defaults to 100 MiB instead of unlimited; larger
bodies get a 413. The cap is enforced chunk-by-chunk on the wire, and
since multipart uploads spool to disk, raising it does not raise memory
use:
api = responder.API(max_request_size=5 * 1024**3) # allow 5 GiB uploads
api = responder.API(max_request_size=None) # pre-9.0 unlimited
Rate-limit errors use Problem Details¶
With the default API(problem_details=True), the rate limiter’s
429 Too Many Requests and fail-closed-backend 503 Service
Unavailable responses now use the same RFC 9457
application/problem+json envelope as framework-generated errors:
{"type": "about:blank", "title": "Too Many Requests",
"status": 429, "detail": "Rate limit exceeded."}
Previously the body was {"error": "rate limit exceeded"} — clients that
parse the error key need updating (or key off the status code and
Retry-After/X-RateLimit-* headers, which are unchanged). To keep the
legacy body shape while migrating, pass API(problem_details=False),
which also keeps the legacy shape for all framework errors.
trust_proxy_headers now rewrites the connection scope¶
In 8.x, API(trust_proxy_headers=True) only changed the client IP recorded
by enable_logging. It now honors the full set of forwarding headers —
RFC 7239 Forwarded, plus X-Forwarded-Proto/-Host/-For and
X-Real-IP — rewriting the request’s scheme, host, and client address for
every layer: HTTPS detection, redirects, URL building, trusted-host
validation, and rate-limit keys. If you enabled the flag purely for logging,
logged IPs are unchanged; the scheme/host handling simply becomes correct
behind your proxy. Leave the flag off (the default) when Responder is
directly exposed.
api.session() was removed¶
The legacy test-client accessor api.session() has been removed. Use the
requests property instead:
r = api.requests.get("http://;/hello")
If you relied on session(base_url=...) for a custom base URL, construct
the client through the supported helper:
client = api.test_client(base_url="http://testserver")
Explicit port= now wins over PORT¶
When api.run() / api.serve() receive both an explicit port= and
a conflicting PORT environment variable, the explicit argument wins:
# PORT=9000 in the environment:
api.run(port=8000) # binds 8000
api.run(port=9000) # binds 9000
api.run() # binds 9000
To keep environment-first behavior, resolve the environment yourself:
api.run(port=int(os.environ.get("PORT", 8000)))
or use the legacy compatibility switch while migrating:
api.run(port=8000, port_precedence="env")
Bare add_route() static fallback¶
Calling api.add_route(route) with no endpoint now raises by default
instead of implicitly registering a default route that serves
static/index.html for every unmatched request. Pass an endpoint
explicitly instead:
import pathlib
async def spa(req, resp):
resp.html = (pathlib.Path("static") / "index.html").read_text()
api.add_route("/", spa, default=True)
Static assets are unaffected: the static_dir / static_route mount
keeps working as-is.
If you need the old fallback while migrating, opt in explicitly:
api = responder.API(implicit_static_fallback=True)
Decimal serializes as a JSON string¶
Assigning a bare decimal.Decimal to resp.media now serializes it
as a JSON string, preserving precision.
Choose a representation explicitly when your API contract needs a number:
resp.media = {"price": str(total)} # exact, v9's default
resp.media = {"price": float(total)} # lossy, JSON number
or keep floats everywhere with the compatibility flag or a custom encoder:
api = responder.API(json_decimal="float")
def encoder(obj):
if isinstance(obj, decimal.Decimal):
return float(obj)
raise TypeError # fall back to the built-in conversions
api = responder.API(encoder=encoder)
GraphQL: 400 with partial data¶
The GraphQL extension now returns HTTP 200 for execution results that
contain both data and errors (a partial result, e.g. one resolver
failed while others succeeded). Per the GraphQL-over-HTTP specification, these are well-formed
GraphQL responses.
Requests that produce no data (validation or request errors) keep their
400. Inspect the errors key of the response body instead of relying
on the status code alone:
api.graphql("/graph", schema=schema)
result = client.post("/graph", json={"query": query}).json()
if result.get("errors"):
... # handle errors, regardless of HTTP status
To preserve the legacy partial-data status while migrating, pass:
api.graphql("/graph", schema=schema, partial_data_status=400)