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
- Mount a Django app:
api.mount_django("/")
- Run under
runbolt with multiple workers.
- 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 /).
- 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.
Environment
runbolt --processes 8 --respawn-failed-workers, Django mounted viaapi.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()crashesthe worker with:
This happens because Django's own ASGI handler runs two concurrent tasks per
request —
process_request()(sends the response) andlisten_for_disconnect()(raises
RequestAbortedon early client disconnect). When the client disconnectswhile
process_request()is mid-way throughsend_response(), both paths end upcalling
send(), and the second call — routed throughmount_django'sdjango_sendwrapper — raises, since nothing in the wrapper (or upstream) guardsagainst a duplicate
http.response.start/http.response.bodyafter 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
runboltprocess (this hit us ~60 times over 2 days, eachknocking ~80 tenant sites offline briefly).
Traceback
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
api.mount_django("/")runboltwith multiple workers.curlwitha short timeout, or in practice: any bot/scanner that opens and drops the
connection, such as Palo Alto Networks Cortex Xpanse scanners hitting
GET /).RuntimeError: http.response.start sent more than onceand dies;--respawn-failed-workersrestarts the whole pool.Expected
A client disconnecting mid-response should not crash the worker. The second
send()call indjango_send(or upstream in howmount_djangowires up theASGI scope/send) should be swallowed once
RequestAborted/disconnect has alreadyfired 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 ahttp.response.startsent after the connection is already known-aborted isdropped instead of forwarded/raised. Alternatively, cancel Django's
listen_for_disconnecttask oncesend_responsebegins, matching howrunserver/uvicorn's own ASGI adapters avoid this race.