Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions tests/test_204_no_body_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# RFC 9110 s15.3.5: a 204 response MUST NOT carry content. On EVERY path.
"""
The sibling of test_head_no_body_conformance.py, for the other status whose
body the spec forbids.

Python did NOT behave correctly: with TINA4_DEBUG=true the dev toolbar was
appended to every 204, because _stage_dev_toolbar_inject gated on dev mode and
text/html and never on the status - and a 204 IS text/html, since Response()
sets that content type unconditionally and `response(None, 204)` (what
`tina4 make crud` scaffolds for every delete handler) leaves it alone.
Measured 2026-09-18 at 3.13.136: a scaffolded DELETE returned 1037 bytes and
declared Content-Length: 1037; an OPTIONS on a known path returned 1032.

Why it matters beyond conformance: under uvicorn's h11 the body is refused
("Too much data for declared Content-Length") and the connection is torn down,
so the NEXT request over it dies with RemoteDisconnected. Under httptools, and
under Tina4's own dev bridge, the bytes simply go out and are discarded - and
with Accept-Encoding: gzip the 204 even goes out gzip-encoded, because the
toolbar pushes the body past the compression threshold in build_headers.

The strip is unconditional and late, for the same reason the HEAD strip is:
whoever put the body there - an injector, a handler that passed content
alongside a 204 - it does not leave. A guard inside one injector protects one
injector.

Driven through the REAL ASGI app: the body is assembled from the
http.response.body messages, which is where a leak would actually show.
"""
import asyncio

import pytest

from tina4_python.core.router import Router, get as route_get, delete as route_delete
from tina4_python.core.server import app


@pytest.fixture(autouse=True)
def _workspace(tmp_path, monkeypatch):
(tmp_path / "src" / "public").mkdir(parents=True)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TINA4_DEBUG", "true")
Router.clear()

@route_get("/page")
async def _page(request, response):
return response("<html><body><h1>hi</h1></body></html>")

@route_delete("/item/1", auth_required=False)
async def _delete(request, response):
return response(None, 204)

@route_get("/handmade")
async def _handmade(request, response):
return response("<b>a 204 the handler gave a body to</b>", 204)

yield tmp_path
Router.clear()


def drive(method, path, headers=None):
"""Return (status, body_bytes, headers) from the real ASGI app."""
sent = []
scope = {"type": "http", "method": method, "path": path, "query_string": b"",
"headers": headers or [], "client": ("127.0.0.1", 1)}

async def receive():
return {"type": "http.request", "body": b"", "more_body": False}

async def send(message):
sent.append(message)

asyncio.run(app(scope, receive, send))
start = next(m for m in sent if m["type"] == "http.response.start")
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
hdrs = {k.decode().lower(): v.decode() for k, v in start.get("headers", [])}
return start["status"], body, hdrs


def test_a_scaffolded_delete_returns_a_204_with_no_body():
status, body, _ = drive("DELETE", "/item/1")
assert status == 204
assert len(body) == 0, (
f"the 204 carried {len(body)} bytes - RFC 9110 s15.3.5 forbids content "
f"in a 204 response"
)


def test_the_204_declares_content_length_zero():
_status, _body, headers = drive("DELETE", "/item/1")
assert headers.get("content-length") == "0", (
"a 204 declaring a non-zero Content-Length is refused by uvicorn's h11 "
"and kills the connection"
)


def test_an_options_on_a_known_path_returns_a_204_with_no_body():
"""s9.3.7: OPTIONS answers 204 + Allow. It is a fallback stage, so unlike
the CORS preflight it DOES fall through the response stages."""
status, body, headers = drive("OPTIONS", "/item/1")
assert status == 204
assert "allow" in {k.lower() for k in headers}, "the Allow header was lost"
assert len(body) == 0


def test_a_204_the_handler_gave_a_body_to_is_stripped_too():
"""The HEAD strip removes a body an explicit Router.head() handler returned
by mistake. This holds the same line."""
status, body, _ = drive("GET", "/handmade")
assert status == 204
assert len(body) == 0


def test_a_204_is_never_gzip_encoded():
"""build_headers compresses above 1024 bytes; the toolbar pushed a 204 past
it, so a no-content response went out Content-Encoding: gzip."""
_status, body, headers = drive(
"DELETE", "/item/1", [(b"accept-encoding", b"gzip")]
)
assert "content-encoding" not in headers
assert len(body) == 0


def test_an_ordinary_html_200_still_gets_the_toolbar():
"""NEGATIVE: the strip must not have cost the toolbar its actual job."""
status, body, _ = drive("GET", "/page")
assert status == 200
assert b"__dev" in body, "the dev toolbar stopped being injected at all"
assert b"<h1>hi</h1>" in body


def test_a_head_still_carries_no_body_and_still_reports_the_length():
"""NEGATIVE: the 204 strip runs beside the HEAD strip; neither may break
the other."""
status, body, headers = drive("HEAD", "/page")
assert status == 200
assert len(body) == 0
assert int(headers["content-length"]) > 0


def test_a_head_on_a_204_reports_content_length_zero():
"""ORDER: the 204 strip must run BEFORE the HEAD strip.

HEAD maps to the GET route, so this is a HEAD on a 204. The HEAD strip
records the length the equivalent GET would have sent - correct for a 200,
wrong for a 204, where the answer is always zero. Run the other way round
and this response carries Content-Length: 39 for a body that is forbidden.
"""
status, body, headers = drive("HEAD", "/handmade")
assert status == 204
assert len(body) == 0
assert headers.get("content-length") == "0", (
"the HEAD strip reported the length of a body a 204 may not have - "
"_stage_no_content_strip is registered after it instead of before"
)


def test_the_dev_dashboard_records_the_size_that_went_on_the_wire():
"""ORDER: the 204 strip must run BEFORE the inspector capture.

_RESPONSE_STAGES says body_size is meant to be what actually went on the
wire - that is why the inspector is placed after the toolbar injection.
Strip the 204 after the inspector has already looked and the dashboard
reports 1037 bytes for a response that ships none.
"""
from tina4_python.dev_admin import RequestInspector

RequestInspector.clear()
_status, body, _ = drive("DELETE", "/item/1")
assert len(body) == 0
recorded = RequestInspector.get()
assert recorded, "the dev inspector recorded nothing at all"
assert recorded[0]["body_size"] == 0, (
f"the dashboard reports {recorded[0]['body_size']} bytes for a 204 that "
f"put {len(body)} on the wire"
)
7 changes: 6 additions & 1 deletion tests/test_dispatch_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,16 @@ def test_the_pipeline_declares_its_stages_in_order():
assert _names(server._RESPONSE_STAGES) == [
"_stage_apply_cors",
"_stage_dev_toolbar_inject",
"_stage_no_content_strip",
"_stage_dev_inspector_capture",
"_stage_request_log",
"_stage_session_save",
"_stage_head_strip",
], "head_strip is LAST or the toolbar puts a body back into a HEAD response"
], (
"head_strip is LAST or the toolbar puts a body back into a HEAD response; "
"no_content_strip sits between the injection and the inspector, or the "
"dashboard reports bytes a 204 never shipped"
)


def test_it_has_no_unnamed_stage():
Expand Down
42 changes: 41 additions & 1 deletion tina4_python/core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2706,6 +2706,42 @@ def _stage_session_save(ctx: DispatchContext) -> None:
return None


def _stage_no_content_strip(ctx: DispatchContext) -> None:
"""RFC 9110 s15.3.5: a 204 response MUST NOT carry content.

Unconditional and late, for the reason ``_stage_head_strip`` below is:
whoever put the body there does not get to keep it. A guard inside a single
injector only protects that injector, and there is more than one. The dev
toolbar appended 8.5KB-class markup to EVERY 204 - it gated on dev mode and
``text/html``, and a 204 IS text/html, because ``Response()`` sets that
content type unconditionally and ``response(None, 204)`` (what `tina4 make
crud` scaffolds for every delete handler) only empties the body. Measured at
3.13.136: a scaffolded DELETE returned 1037 bytes declaring Content-Length
1037, and an OPTIONS on a known path returned 1032. The feedback widget in
``app()`` reads that same content type from the far side of ``handle()``,
and a handler is free to pass content alongside a 204 itself.

It is not cosmetic. Under uvicorn's h11 the body is refused outright
("Too much data for declared Content-Length") and the connection is torn
down, so the NEXT request over it dies with RemoteDisconnected; the DELETE
that caused it still returned a clean 204. Under httptools, and under the
built-in dev bridge, the bytes just go out and are thrown away - and past
1024 bytes ``build_headers`` compresses, so a no-content response went on
the wire Content-Encoding: gzip.

Position is behaviour twice over. It runs BEFORE ``_stage_dev_inspector_capture``,
or the dev dashboard reports the 1037 bytes the toolbar wrote for a response
that ships none - the stage list's own comment says body_size is meant to be
what went on the wire. And BEFORE ``_stage_head_strip``, so a HEAD on a 204
reports Content-Length: 0 and not the length of a body that was never
allowed to exist.
"""
if ctx.response.status_code != 204:
return None
ctx.response.content = b""
return None


def _stage_head_strip(ctx: DispatchContext) -> None:
"""RFC 9110 s9.3.2: a HEAD response MUST NOT include content.

Expand Down Expand Up @@ -2747,7 +2783,10 @@ def _stage_head_strip(ctx: DispatchContext) -> None:
#: Run over the finished Response on the way out, in order.
#:
#: Order is BEHAVIOUR: the inspector runs AFTER the toolbar injection so its
#: body_size reports what went on the wire, and ``_stage_head_strip`` is LAST
#: body_size reports what went on the wire, and ``_stage_no_content_strip`` sits
#: BETWEEN them for exactly that reason - place it later and the dashboard
#: reports 1037 bytes for a 204 that ships none. It is also before the HEAD
#: strip, so a HEAD on a 204 reports Content-Length: 0. ``_stage_head_strip`` is LAST
#: because the toolbar injection would otherwise put 8.5KB of markup back into
#: an already-stripped HEAD response - the body removed and then restored. A
#: run without TINA4_DEBUG could not see that. Stripping last also makes
Expand All @@ -2756,6 +2795,7 @@ def _stage_head_strip(ctx: DispatchContext) -> None:
_RESPONSE_STAGES = (
_stage_apply_cors,
_stage_dev_toolbar_inject,
_stage_no_content_strip,
_stage_dev_inspector_capture,
_stage_request_log,
_stage_session_save,
Expand Down