Skip to content

mount_django: client disconnect mid-response races with send_response, causing RuntimeError: http.response.start sent more than once #313

Description

@xeroticikot

Environment

  • django-bolt: 0.10.2
  • Django: 6.0.4
  • Python: 3.14
  • Deployment: runbolt --processes 8 --respawn-failed-workers, Django mounted via api.mount_django(...)

Description

Under normal traffic (including bots/scanners that open a connection and abort it
before the response finishes), the Django app mounted via mount_django() crashes
the worker with:

RuntimeError: http.response.start sent more than once

This happens because Django's own ASGI handler runs two concurrent tasks per
request — process_request() (sends the response) and listen_for_disconnect()
(raises RequestAborted on early client disconnect). When the client disconnects
while process_request() is mid-way through send_response(), both paths end up
calling send(), and the second call — routed through mount_django's
django_send wrapper — raises, since nothing in the wrapper (or upstream) guards
against a duplicate http.response.start/http.response.body after the first.

With --respawn-failed-workers, this kills and restarts the entire worker pool
(all 8 processes) on a single aborted client request, causing a several-second
full outage for every request in flight — worse in a multi-tenant setup where many
domains share one runbolt process (this hit us ~60 times over 2 days, each
knocking ~80 tenant sites offline briefly).

Traceback

Task exception was never retrieved
future: <Task finished name='Task-3469' coro=<ASGIHandler.handle.<locals>.process_request() done, defined at .../django/core/handlers/asgi.py:192> exception=RuntimeError('http.response.start sent more than once')>
Traceback (most recent call last):
  File ".../django/core/handlers/asgi.py", line 195, in process_request
    await self.send_response(response, send)
  File ".../django/core/handlers/asgi.py", line 333, in send_response
    await send(
    ...<5 lines>...
    )
  File ".../django_bolt/api.py", line 3059, in django_send
    await send(rewritten)
RuntimeError: http.response.start sent more than once

Preceded immediately by (same request cycle):
future: <Task finished name='Task-3468' coro=<ASGIHandler.listen_for_disconnect() done, defined at .../django/core/handlers/asgi.py:241> exception=RequestAborted()>
Traceback (most recent call last):
File ".../django/core/handlers/asgi.py", line 245, in listen_for_disconnect
raise RequestAborted()
django.core.exceptions.RequestAborted

Repro

  1. Mount a Django app: api.mount_django("/")
  2. Run under runbolt with multiple workers.
  3. Send a request and abort the client connection mid-response (e.g. curl with
    a short timeout, or in practice: any bot/scanner that opens and drops the
    connection, such as Palo Alto Networks Cortex Xpanse scanners hitting GET /).
  4. Worker raises RuntimeError: http.response.start sent more than once and dies;
    --respawn-failed-workers restarts the whole pool.

Expected

A client disconnecting mid-response should not crash the worker. The second
send() call in django_send (or upstream in how mount_django wires up the
ASGI scope/send) should be swallowed once RequestAborted/disconnect has already
fired for that request — the same way Daphne/Uvicorn handle this race today
without raising into user/framework code.

Suggested fix direction

Guard django_send (api.py, mount_django) with a one-shot flag so a
http.response.start sent after the connection is already known-aborted is
dropped instead of forwarded/raised. Alternatively, cancel Django's
listen_for_disconnect task once send_response begins, matching how
runserver/uvicorn's own ASGI adapters avoid this race.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions