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 send until 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_iterator and FileResponse default 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_size default, so moving off waitress changes nothing.

ploneasgi.wsgi.DISCONNECTED_KEY = 'ploneasgi.disconnected'#

Set to True on 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.BodyTooLarge[source]#

The request body is larger than the adapter accepts.

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_size so that memory stays bounded, and is refused past max_body_size so 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 await it there either.

Parameters:
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:

Any

ploneasgi.wsgi.build_environ(scope, body)[source]#

Build a PEP 3333 environ from an ASGI HTTP scope.

Parameters:
  • scope (MutableMapping[str, Any]) -- The ASGI connection scope.

  • body (Any) -- A file-like object positioned at the start of the request body.

Returns:

The WSGI environ.

Return type:

dict[str, Any]

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; None for no limit. Larger bodies are refused with a 413 before the app runs.

Return type:

None