diff --git a/pyproject.toml b/pyproject.toml index 872e51a5..4303c711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ optional-dependencies = { test-tools = [ "drf-spectacular (>=0.28.0,<1)", "drf-writable-nested", "environs (<16)", - "gunicorn (>=19.1)", + "gunicorn (>=23)", "opentelemetry-instrumentation-django (>=0.46b0,<1)", "opentelemetry-instrumentation-psycopg2 (>=0.46b0,<1)", "opentelemetry-instrumentation-redis (>=0.46b0,<1)", diff --git a/src/common/gunicorn/utils.py b/src/common/gunicorn/utils.py index 1f954fbb..55c97a1a 100644 --- a/src/common/gunicorn/utils.py +++ b/src/common/gunicorn/utils.py @@ -23,6 +23,7 @@ "accesslog": env.str("ACCESS_LOG_LOCATION", os.devnull), "bind": "0.0.0.0:8000", "config": "python:common.gunicorn.conf", + "keepalive": env.int("GUNICORN_KEEP_ALIVE", 2), "logger_class": "common.gunicorn.logging.GunicornJsonCapableLogger", "statsd_prefix": "flagsmith.api", "threads": env.int("GUNICORN_THREADS", 1), @@ -41,9 +42,19 @@ def __init__(self, options: dict[str, Any] | None) -> None: def load_config(self) -> None: cfg_settings = self.cfg.settings + env_args = self.cfg.parser().parse_args(self.cfg.get_cmd_args_from_env()) + env_options = { + key: value + for key, value in vars(env_args).items() + if key != "args" and value is not None + } options_items = ( (key, value) - for key, value in {**GUNICORN_FLAGSMITH_DEFAULTS, **self.options}.items() + for key, value in { + **GUNICORN_FLAGSMITH_DEFAULTS, + **env_options, + **self.options, + }.items() if key in cfg_settings ) for key, value in options_items: diff --git a/tests/unit/common/gunicorn/test_utils.py b/tests/unit/common/gunicorn/test_utils.py index 6aa84986..2d231593 100644 --- a/tests/unit/common/gunicorn/test_utils.py +++ b/tests/unit/common/gunicorn/test_utils.py @@ -2,11 +2,14 @@ import signal import threading import time +from collections.abc import Generator +from importlib import reload import pytest from drf_spectacular.generators import EndpointEnumerator from pytest_mock import MockerFixture +import common.gunicorn.utils from common.gunicorn.utils import ( DjangoWSGIApplication, get_route_template, @@ -34,9 +37,72 @@ def test_django_wsgi_application__defaults__expected_config( == "common.gunicorn.logging.GunicornJsonCapableLogger" ) assert settings["config"].value == "python:common.gunicorn.conf" + assert settings["keepalive"].value == 2 + assert settings["threads"].value == 1 + assert settings["timeout"].value == 30 + assert settings["workers"].value == 1 assert app.load_wsgiapp() == wsgi_handler_mock +@pytest.fixture +def reload_gunicorn_utils() -> Generator[None, None, None]: + yield + reload(common.gunicorn.utils) + + +@pytest.mark.usefixtures("reload_gunicorn_utils") +def test_django_wsgi_application__env_vars_set__expected_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given + monkeypatch.setenv("GUNICORN_KEEP_ALIVE", "65") + monkeypatch.setenv("GUNICORN_THREADS", "2") + monkeypatch.setenv("GUNICORN_TIMEOUT", "60") + monkeypatch.setenv("GUNICORN_WORKERS", "3") + utils = reload(common.gunicorn.utils) + + # When + app = utils.DjangoWSGIApplication({}) + + # Then + settings = app.cfg.settings + assert settings["keepalive"].value == 65 + assert settings["threads"].value == 2 + assert settings["timeout"].value == 60 + assert settings["workers"].value == 3 + + +def test_django_wsgi_application__gunicorn_cmd_args_set__overrides_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given + monkeypatch.setenv("GUNICORN_CMD_ARGS", "--keep-alive 65 --workers 4") + + # When + app = DjangoWSGIApplication({}) + + # Then + settings = app.cfg.settings + assert settings["keepalive"].value == 65 + assert settings["workers"].value == 4 + assert settings["config"].value == "python:common.gunicorn.conf" + + +def test_django_wsgi_application__gunicorn_cmd_args_and_options_set__options_take_precedence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given + monkeypatch.setenv("GUNICORN_CMD_ARGS", "--keep-alive 65 --workers 4") + + # When + app = DjangoWSGIApplication({"keepalive": 10}) + + # Then + settings = app.cfg.settings + assert settings["keepalive"].value == 10 + assert settings["workers"].value == 4 + + def test_run_server__default_config_file__runs_expected( unused_tcp_port: int, mocker: MockerFixture, diff --git a/uv.lock b/uv.lock index 68cdb49a..93c8a3d4 100644 --- a/uv.lock +++ b/uv.lock @@ -548,7 +548,7 @@ requires-dist = [ { name = "environs", marker = "extra == 'task-processor'", specifier = "<16" }, { name = "flagsmith-common", extras = ["otel"], marker = "extra == 'common-core'" }, { name = "flagsmith-flag-engine", marker = "extra == 'flagsmith-schemas'", specifier = ">6" }, - { name = "gunicorn", marker = "extra == 'common-core'", specifier = ">=19.1" }, + { name = "gunicorn", marker = "extra == 'common-core'", specifier = ">=23" }, { name = "inflection", marker = "extra == 'otel'" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "opentelemetry-api", marker = "extra == 'task-processor'", specifier = ">=1.25,<2" },