Django host notes: request roots
These notes are django-neolith’s implementation documentation, not part of any decision. They record how the host meets the request-root rules of ADR 0017 Decision 4 on Django 5.2, and the edge cases found in review. The conformance cases at the end are in the test plan of roadmap step 4 (#11). Where these notes and ADR 0017 differ, the record governs.
Middleware. The host ships two middleware classes. Both are sync- and async-capable, and Django runs each in the mode of the handler inside it: under ASGI that is async, unless a sync-only middleware follows in MIDDLEWARE, in which case Django runs them in sync mode behind sync_to_async (core/handlers/base.py, load_middleware). They behave the same in both modes. A class that is missing or out of order stops the host at startup (K2); ADR 0022, which is Proposed, puts that check at set-up and in each class’s constructor.
- The root middleware is placed outermost. It opens the root when the request starts and keeps a reference to it. Closing always goes through that reference, because on ASGI the middleware runs inside a task with its own copy of the context, so nothing outside it could find the root through the variable.
- The principal middleware is placed after
AuthenticationMiddleware. Its response phase therefore runs beforeSessionMiddleware’s, and the streamed-body case below relies on that.
Which app. The app is found from the view_func that process_view receives, using apps.get_containing_app_config, and the result is cached per view.
ModelAdminviews. Django wraps these so that their module isdjango.contrib.admin.optionsand marks them withmodel_admin(ModelAdmin.get_urls). The host takes the app that contains the module oftype(view_func.model_admin). For the stockModelAdmin, which runs no project code, it takes the model’s app. A subclass in no installed app is project code: it gets no caller and aneolith checkwarning.- Class-based views take the view class’s module.
- Other views take the view function’s module as
functools.wrapsleaves it. A decorator that does not copy__module__makes the view look like part of the decorator’s app, and the documentation says so. - Views supplied by another installed app (
django.contrib.auth’s login view, the admin site’s own views, a third-party app’s views) run as that app.
Binding. The root middleware binds the app in process_view. Django calls it after resolve_request and after every middleware’s request phase. Because this middleware is outermost, its process_view runs before every other middleware’s, so the rest of the request runs in the bound root: later process_view, process_exception and process_template_response methods and the response phase. A call made from another middleware’s request phase, or on a request that never reaches a view, comes before binding.
Principal and trace. process_view keeps the request and the object request.user holds at that moment, without evaluating it. The principal and the trace identifier are resolved together, once per root, by the first connector call that needs them; concurrent first calls share one resolution. The trace identifier is read from traceparent, or generated. The principal comes from the request’s user:
- If
request.useris no longer the object kept atprocess_view, thenlogin,logout,aloginoralogouthas replaced it with a plain instance, and the host uses that instance. This needs no I/O and is safe in the event loop. - Otherwise the host uses
request.useron the sync path andawait request.auser()on the async path.auser()keeps its own cache, whichaloginandalogoutdo not clear; the identity check above is what keeps a view that callsalogoutand then a contract from calling on behalf of the user who just logged out.
The principal in force at the first call is the root’s principal. A view that logs a user in or out after its first call keeps the old principal, and calls that must run as the new user open their own scope.
Error and failure views. Django calls handler400, handler403, handler404, handler500 and CSRF_FAILURE_VIEW itself; process_view does not run for them. They run in the root as it stands:
- unbound when the error comes before
process_view, which covers an unresolved URL (also the 404 thatAPPEND_SLASHandLocaleMiddlewareturn into a redirect) and an exception in a request phase; - bound when the error comes from the view or from a later
process_viewsuch as the CSRF check.
So an error view that calls a contract opens its own scope (ADR 0017 Decision 4).
Closing. How the root closes depends on the response:
- Ordinary response. The root middleware closes the root and resets the variable in a
finallyblock when it returns, so aBaseExceptionthat escapes the chain also closes it, such as theCancelledErrorthe ASGI handler raises on a client disconnect.TemplateResponseis rendered inside_get_response, before any middleware returns. FileResponsewithfile_to_streamset. It is left unwrapped, and its root closes when the middleware returns. Replacing itsstreaming_contentwould clearfile_to_stream(FileResponse._set_streaming_content), andWSGIHandlerwould then skipwsgi.file_wrapperand lose sendfile. Reading a file runs no app code.- Any other streaming response. Its body is iterated after the middleware returns, by the WSGI server or by the ASGI handler’s
send_response. The root middleware hands the root to a wrapper aroundstreaming_content, which is a sync or async iterator object matchingresponse.is_asyncand has aclose()method, asGZipMiddlewarewraps a body. An async generator would not work: Django registers acloseattribute, and async generators lack one.- Inside the root. Each step sets the variable to the root around the item it pulls, so the body runs inside the root in whatever context the server iterates it.
- Normal close. The wrapper closes the root when the inner iterator is exhausted, raises, or is closed. Django registers the wrapper’s
close(), andHttpResponseBase.close()calls it, after the body on WSGI and aftersend_responseon ASGI, including on a client disconnect during the send. - Principal and
Vary. Before a streaming response is returned from a bound root whose principal is unresolved, the principal middleware’s response phase resolves it.SessionMiddlewarethen sees the session access, addsVary: Cookie, and saves any changeget_usermade to the session (flush, orcycle_keyon a fallback hash). The root middleware also patchesVarywith the request inputs the 0010 backend declares on every wrapped streaming response from a bound root (Cookiefor the session-based Django user). This is the per-request cost Decision 4 allows for streamed bodies, along with one context-variable set and reset per chunk. - Late-close backstop. A
weakref.finalizeon the wrapper closes the root when the wrapper is collected. One ASGI path needs it. For a streaming response with status 400 or above,get_response_asyncawaitslog_responseafter the chain returns, and a client disconnect during that await cancels the request task beforesend_responsestarts. The handler then sendsrequest_finishedand never callsresponse.close(). The wrapper and the response always form a reference cycle, so the root closes at the next cyclic garbage collection that reaches it, with no fixed bound. Until then the body never runs, but an executor that inherited the root can still use it, and each call it makes is bounded only by its own deadline (spec 02).
Sync and async views. A sync view under ASGI runs through sync_to_async, and an async view under WSGI through async_to_sync. Both carry context variables across the switch. Thread-sensitive mode shares one thread, but each call runs in its own copy of the context, so roots do not leak between requests.
Conformance cases for the Django host (on WSGI and ASGI, with a sync and an async view): A connector call made from a StreamingHttpResponse body while the server iterates it succeeds, with both a sync and an async iterator; a call held over until after the response is closed fails; on ASGI, a client disconnect during a streamed body closes the root; on ASGI, a streaming 4xx response whose client disconnects before the body is sent has its root closed by the backstop once the wrapper is collected (the test forces a collection); a FileResponse keeps file_to_stream, and its root is closed when the middleware returns; and a call from another middleware’s request phase, before process_view, is refused. The caller is tested too: an admin action registered in one app’s admin.py calls a contract with that app as the caller, not django.contrib.admin; an admin action on a ModelAdmin subclass in a project-level module has no caller and is refused, and neolith check warns about it; a page that makes no connector call gets no Vary: Cookie from the host and does not evaluate request.user, while a streamed body whose first connector call happens during iteration gets Vary: Cookie and its principal resolved before the response is returned; an async view that calls alogout and then a contract calls with no principal, not as the user who logged out; two concurrent first calls share one principal and one trace identifier; a view from another installed app calls as that app; and an error view rendered for an unresolved URL has no caller.