fix(api): uvicorn worker keepalive (#11663)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Josema Camacho
2026-06-22 16:30:33 +02:00
committed by GitHub
parent 5ee8b9680d
commit 2375f1d962
4 changed files with 53 additions and 30 deletions
+19 -28
View File
@@ -3,6 +3,8 @@ import multiprocessing
import os
import threading
from uvicorn_worker import UvicornWorker
from config.env import env
# Ensure the environment variable for Django settings is set
@@ -12,6 +14,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production")
import django # noqa: E402
django.setup()
from api.compliance import warm_compliance_caches # noqa: E402
from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402
from config.custom_logging import BackendLogger # noqa: E402
@@ -19,34 +22,28 @@ from config.custom_logging import BackendLogger # noqa: E402
BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1")
PORT = env("DJANGO_PORT", default=8080)
class ProwlerUvicornWorker(UvicornWorker):
CONFIG_KWARGS = {
# Keep-alive idle timeout. Must exceed the load balancer idle timeout.
"timeout_keep_alive": env.int("GUNICORN_KEEPALIVE", default=75),
"loop": "uvloop",
"lifespan": "off", # Django ASGIHandler doesn't handle lifespan scopes
}
# Required so SSE endpoints can keep the event loop alive while waiting for events
worker_class = env(
"DJANGO_WORKER_CLASS",
default="config.guniconf.ProwlerUvicornWorker",
)
# Server settings
bind = f"{BIND_ADDRESS}:{PORT}"
workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1)
reload = DEBUG
# Native ASGI worker (gunicorn 24+). Required so SSE endpoints can keep the
# event loop alive while waiting for events.
worker_class = env("DJANGO_WORKER_CLASS", default="asgi")
# Lifespan protocol. Django's ASGIHandler (config.asgi:application) serves only
# HTTP scopes and raises "Django can only handle ASGI/HTTP connections, not
# lifespan." gunicorn's default ("auto") probes the app with a lifespan scope
# to detect support, which triggers that error. We use no lifespan startup or
# shutdown hooks, so disable the protocol entirely.
asgi_lifespan = env("DJANGO_ASGI_LIFESPAN", default="off")
# Event loop for the ASGI worker. "auto" uses uvloop when it is installed and
# falls back to the stdlib asyncio loop otherwise; uvloop gives the SSE event
# loop more headroom under many concurrent open streams.
asgi_loop = env("DJANGO_ASGI_LOOP", default="uvloop")
# Max concurrent connections per ASGI worker. Each open SSE stream holds one
# connection for its whole lifetime, so this caps simultaneous SSE clients per
# worker (gunicorn's default is 1000). The sync-only `threads` option has no
# effect on ASGI workers.
worker_connections = env.int("DJANGO_WORKER_CONNECTIONS", default=1000)
# Preload the application before forking workers in production: the app is
# imported once in the master and workers fork from it. In development, disable
# preload so the server restarts on code changes.
@@ -56,12 +53,6 @@ preload_app = not DEBUG
# that may take longer, such as complex API operations.
timeout = env.int("GUNICORN_TIMEOUT", default=120)
# HTTP keep-alive idle timeout. Must exceed the idle timeout of the proxy or load
# balancer in front of gunicorn, or it reuses a connection gunicorn just closed
# and returns a 502. Default clears the common 60s; raise `GUNICORN_KEEPALIVE` to
# stay above a longer one.
keepalive = env.int("GUNICORN_KEEPALIVE", default=75)
# Logging
logconfig_dict = DJANGO_LOGGERS
gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN)