ploneasgi.wsgi#
An ASGI↔WSGI adapter that never holds a worker thread waiting for a client.
This replaces a2wsgi.WSGIMiddleware. The difference is where the
response body is consumed.
a2wsgi runs the whole WSGI call — including iterating the response body — inside
a worker thread, and pushes each chunk back to the event loop with a blocking
run_coroutine_threadsafe(...).result(). Two consequences, both measured:
Every chunk costs a round trip through the single event loop, which serializes workers against each other. Measured 2026-08-02: the translation layer is 66% of the ASGI overhead versus waitress, and its cost grows with concurrency (+395 µs at c=1, +1148 µs at c=4) while native ASGI's does not.
A worker thread stays blocked in
senduntil the client accepts the bytes. One visitor on bad wifi therefore pins one thread for the length of their download. Measured 2026-08-03: with 4 threads, 4 concurrent slow downloads stop the site answering page requests entirely.
Here the split is different:
worker thread call the WSGI app, return (status, headers, iterable)
event loop pull one chunk at a time and send it
The app call is a single thread hop. Producing each chunk is a short hop of its own — so the thread is held for the work, never for the transfer. Waiting on a slow client happens on the event loop, holding nothing.
That holds for every response Zope streams, not only blob downloads.
ZPublisher already closes the ZODB connection before returning its body
iterable, and says so in ZPublisher.Iterators.IStreamIterator:
"After the application finishes interpreting a request and returns an iterator to be processed asynchronously, it closes the ZODB connection."
Zope has described this architecture since Zope 2. What was missing was a server that could actually process the iterator asynchronously.
Note
Nothing in this module imports Zope. It is a general WSGI adapter and is tested as one.
- ploneasgi.wsgi.CHUNK_SIZE = 262144#
Bytes pulled from a streaming body per thread hop.
64 KB is what
filestream_iteratorandFileResponsedefault to, but each chunk here costs a thread dispatch, so the default is larger. Measured 2026-08-03 on a 50 MB blob at c=4: 666 MiB/s at 64 KB, 1757 at 256 KB, 3758 at 1 MB. Past 256 KB the gain is measured on a warm page cache over loopback and pays for itself in memory held per in-flight response and coarser backpressure for slow clients — which is the case that matters.
- ploneasgi.wsgi.SPOOL_MAX_SIZE = 524288#
Request bodies larger than this spill to a temporary file instead of RAM.
- ploneasgi.wsgi.MAX_BODY_SIZE = 1073741824#
Largest request body accepted, in bytes; larger ones get a 413. waitress's
max_request_body_sizedefault, so moving off waitress changes nothing.
- ploneasgi.wsgi.DISCONNECTED_KEY = 'ploneasgi.disconnected'#
Set to
Trueon the ASGI scope when the client went away mid-response, so the access log can say so instead of reporting a truncated response.
- exception ploneasgi.wsgi.ClientGone[source]#
The client disconnected before its request body arrived.
- async ploneasgi.wsgi.read_body(scope, receive, *, max_body_size=1073741824, spool_max_size=524288)[source]#
Drain a request body into a seekable file-like object.
The whole body is read before anything runs in a worker thread. a2wsgi instead streams it lazily, at the cost of a blocking round trip to the event loop every time the app reads — the very thing this adapter removes. The trade is that a large upload is buffered; it spills to disk past
spool_max_sizeso that memory stays bounded, and is refused pastmax_body_sizeso that the disk does too.Shared with the native HTTP routes, which hand the body to a publisher request in a worker thread and so cannot
awaitit there either.- Parameters:
scope (MutableMapping[str, Any]) -- The ASGI connection scope, for
Content-Length.receive (Callable[[], Awaitable[MutableMapping[str, Any]]]) -- The ASGI receive callable.
max_body_size (int | None) -- Largest body accepted;
Nonefor no limit.spool_max_size (int) -- Size above which the body spills to disk.
- Returns:
A file-like object positioned at the start of the body. The caller closes it.
- Raises:
BodyTooLarge -- When the body exceeds
max_body_size— declared, or actually received, since chunked uploads declare nothing.ClientGone -- When the client disconnects mid-body. The app is never called with a truncated body: an upload cut short would otherwise be processed as if it were complete.
- Return type:
- ploneasgi.wsgi.build_environ(scope, body)[source]#
Build a PEP 3333 environ from an ASGI HTTP scope.
- class ploneasgi.wsgi.WSGIMiddleware[source]#
Mount a WSGI application inside an ASGI application.
- Variables:
app_limiter -- Bounds concurrent WSGI calls. This is the number that must be coordinated with the ZODB
pool-size: every in-flight call may hold a database connection.io_limiter -- Bounds concurrent body chunk reads. Deliberately separate and larger: pulling bytes off a blob file touches no ZODB connection, so letting downloads contend for the same slots as page renders would reintroduce the starvation this adapter exists to remove.
- __init__(app, *, workers=4, io_workers=None, chunk_size=262144, spool_max_size=524288, max_body_size=1073741824)[source]#
- Parameters:
app (Callable[[...], Iterable[bytes]]) -- The WSGI application to mount.
workers (int) -- Maximum concurrent WSGI calls.
io_workers (int | None) -- Maximum concurrent body reads; defaults to
max(4 * workers, 16).chunk_size (int) -- Bytes to pull per thread hop from a streaming body.
spool_max_size (int) -- Request body size above which to spill to disk.
max_body_size (int | None) -- Largest request body accepted;
Nonefor no limit. Larger bodies are refused with a 413 before the app runs.
- Return type:
None