ASGI 2.4 closed-send errors — 2026-07-11¶
Status: accepted as the completion of the advertised ASGI HTTP 2.4 lifecycle boundary.
Gap and semantics¶
servery advertised scope["asgi"]["spec_version"] == "2.4", but a send()
after the final response event could write again, and some peer write failures
escaped as ordinary application exceptions. The
ASGI HTTP specification
says a send on a closed connection should raise a server-specific OSError
subclass and that the server must not log the resulting uncaught exception as an
application fault.
The accepted policy distinguishes:
- completed request scope:
_body_complete is None, the terminal marker already introduced for post-responsereceive(); - closed transport:
StreamWriter.is_closing()before a send; - native peer write failure: non-timeout
OSErrorfrom header/body writes or intermediate drains; and - server write-progress timeout:
TimeoutError, which retains the existing server-owned abort path rather than being mislabeled as peer disconnect.
The first three raise ClientDisconnectError(OSError) to the application. If
the application catches it, cleanup proceeds normally. If it does not, the
exchange consumes the expected lifecycle exception without an error log. A
fully framed response can still leave the physical HTTP/1 connection reusable;
an uncaught extra send therefore does not discard a valid pipelined next request.
Actual peer loss naturally reaches EOF on the next parser iteration.
This is not configurable. The exception type is an advertised interface rule, not operator resource policy.
Correctness evidence¶
Tests cover:
- a send after a final response raises
ClientDisconnectError, which is anOSErrorsubclass; - applications can catch that exception;
- an uncaught exception produces no
ASGI app errorlog and two pipelined requests still receive complete responses; - an already-closing writer raises before accepting response state;
- native
BrokenPipeErroris normalized to the server-specific exception; and - blocked-drain write timeouts remain
TimeoutError.
Existing real/synthetic receive-disconnect, response framing, streaming, backpressure, keep-alive, TLS, and pipeline tests protect the surrounding lifecycle.
Performance gate¶
Artifacts:
benchmarks/artifacts/closed-send-final-2026-07-11.json;- diagnostic
benchmarks/artifacts/closed-send-v1-quick.json.
The final five-trial gate uses CPython 3.15.0b3 with the GIL, one isolated server CPU, disjoint client CPUs, balanced order, one-second warmup, five-second trials, correct response probes, and zero timed errors.
| Scenario | Connections | Paired RPS change | RPS ratio MAD | Paired p99 change | p99 ratio MAD |
|---|---|---|---|---|---|
| ASGI keep-alive 1 KiB | 64 | -3.36% | 3.15 points | +1.26% | 2.59 points |
| ASGI churn 1 KiB | 32 | -2.93% | 2.09 points | +0.73% | 1.07 points |
Both throughput points stay inside the protected 5% budget and are comparable to dispersion; p99 and peak memory are neutral. The preceding three-trial probe was +7.5% keep-alive and +1.0% churn, so no directional performance claim is warranted.
The exact image is sha256:f983522a... with product-tree hash 4104854d....
Decision and follow-ups¶
Accept the boundary and retain ASGI HTTP spec version 2.4. The remaining ASGI work is broader compatibility and feature coverage rather than a known mismatch in the core HTTP request/response/disconnect lifecycle:
- Starlette/FastAPI/Uvicorn application cohorts;
- response-message ordering/validation and trailers;
- server-shutdown and TLS half-close races; and
- HTTP/2-to-ASGI if that adapter is added.
Verification¶
- 822 functional tests pass on CPython 3.15 with the GIL and 822 pass on its free-threaded build; both populated environments have four optional skips.
- Focused ASGI lifecycle tests pass on CPython 3.14.
- Repository-wide Ruff lint/format,
git diff --check, Bandit, ty, and strict MkDocs pass. - Fresh wheel and source distributions build; the wheel has no unconditional runtime dependencies and passes import/CLI smoke outside the source tree.