├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── docs ├── docs │ ├── index.md │ ├── logging.md │ └── reloader.md └── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── scripts ├── build ├── check ├── coverage ├── install ├── lint └── test ├── tests ├── __init__.py ├── conftest.py └── test_workers.py └── uvicorn_worker ├── __init__.py └── _workers.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | python-packages: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: monthly 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | tests: 13 | name: | 14 | Py ${{ matrix.python-version }} / 15 | Uvicorn${{matrix.uvicorn-version == '' && ' (latest)' || format(' {0}', matrix.uvicorn-version) }} / 16 | Gunicorn${{matrix.gunicorn-version == '' && ' (latest)' || format(' {0}', matrix.gunicorn-version) }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ["3.9", "3.10", "3.11", "3.12"] 21 | uvicorn-version: ["0.15.0", ""] 22 | gunicorn-version: ["20.1.0", ""] 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "${{ matrix.python-version }}" 28 | - name: Install dependencies 29 | run: | 30 | pip install "${{ format('uvicorn{0}{1}', matrix.uvicorn-version != '' && '==' || '', matrix.uvicorn-version) }}" 31 | pip install "${{ format('gunicorn{0}{1}', matrix.gunicorn-version != '' && '==' || '', matrix.gunicorn-version) }}" 32 | scripts/install 33 | - name: Run linting checks 34 | run: scripts/check 35 | - name: Build package 36 | run: scripts/build 37 | - name: Run tests 38 | run: scripts/test 39 | timeout-minutes: 10 40 | - name: Enforce coverage 41 | run: scripts/coverage 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | environment: release 13 | 14 | permissions: 15 | id-token: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: Install dependencies 24 | run: scripts/install 25 | 26 | - name: check version 27 | uses: samuelcolvin/check-python-version@v4.1 28 | with: 29 | version_file_path: uvicorn_worker/__init__.py 30 | 31 | - name: Build package 32 | run: scripts/build 33 | 34 | - name: Upload package to PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist 3 | .coverage* 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2024, Marcelo Trylesinski. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uvicorn Worker 2 | 3 | The **Uvicorn Worker** is a package designed for the mature and comprehensive server and process manager, [Gunicorn][gunicorn]. 4 | This package allows you to run ASGI applications, leverages [Uvicorn][uvicorn]'s high-performance capabilities, and provides Gunicorn's 5 | robust process management. 6 | 7 | By using this package, you can dynamically adjust the number of worker processes, restart them gracefully, and execute server 8 | upgrades without any service interruption. 9 | 10 | ## Getting Started 11 | 12 | ### Installation 13 | 14 | You can easily install the Uvicorn Worker package using pip: 15 | 16 | ```bash 17 | pip install uvicorn-worker 18 | ``` 19 | 20 | ## Deployment 21 | 22 | For production environments, it's recommended to utilize Gunicorn with the Uvicorn worker class. 23 | Below is an example of how to do this: 24 | 25 | ```bash 26 | gunicorn example:app -w 4 -k uvicorn_worker.UvicornWorker 27 | ``` 28 | 29 | In the above command, `-w 4` instructs Gunicorn to initiate 4 worker processes, and `-k uvicorn_worker.UvicornWorker` flag tells 30 | Gunicorn to use the Uvicorn worker class. 31 | 32 | If you're working with a [PyPy][pypy] compatible configuration, you should use `uvicorn_worker.UvicornH11Worker`. 33 | 34 | ### Development 35 | 36 | During development, you can directly run Uvicorn as follows: 37 | 38 | ```bash 39 | uvicorn example:app --reload 40 | ``` 41 | 42 | The `--reload` flag will automatically reload the server when you make changes to your code. 43 | 44 | For more information read the [Uvicorn documentation](https://www.uvicorn.org/). 45 | 46 | [gunicorn]: https://gunicorn.org/ 47 | [uvicorn]: https://www.uvicorn.org/ 48 | [pypy]: https://www.pypy.org/ 49 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Uvicorn Worker 2 | 3 | For full documentation visit [mkdocs.org](https://www.mkdocs.org). 4 | 5 | ## Commands 6 | 7 | * `mkdocs new [dir-name]` - Create a new project. 8 | * `mkdocs serve` - Start the live-reloading docs server. 9 | * `mkdocs build` - Build the documentation site. 10 | * `mkdocs -h` - Print help message and exit. 11 | 12 | ## Project layout 13 | 14 | mkdocs.yml # The configuration file. 15 | docs/ 16 | index.md # The documentation homepage. 17 | ... # Other markdown pages, images and other files. 18 | -------------------------------------------------------------------------------- /docs/docs/logging.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-worker/e1c676123402fd219b1497fb09bd969aa7c428ec/docs/docs/logging.md -------------------------------------------------------------------------------- /docs/docs/reloader.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-worker/e1c676123402fd219b1497fb09bd969aa7c428ec/docs/docs/reloader.md -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Uvicorn Worker 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "uvicorn-worker" 7 | description = "Uvicorn worker for Gunicorn! ✨" 8 | readme = "README.md" 9 | authors = [{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }] 10 | classifiers = [ 11 | "Development Status :: 3 - Alpha", 12 | "License :: OSI Approved :: MIT License", 13 | "Intended Audience :: Developers", 14 | "Natural Language :: English", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | ] 23 | license = "BSD-3-Clause" 24 | requires-python = ">=3.9" 25 | dependencies = ["uvicorn>=0.15.0", "gunicorn>=20.1.0"] 26 | optional-dependencies = {} 27 | dynamic = ["version"] 28 | 29 | [project.urls] 30 | Changelog = "https://github.com/Kludex/uvicorn-worker/releases" 31 | Funding = "https://github.com/sponsors/Kludex" 32 | Source = "https://github.com/Kludex/uvicorn-worker" 33 | 34 | [tool.hatch.version] 35 | path = "uvicorn_worker/__init__.py" 36 | 37 | [tool.mypy] 38 | warn_unused_ignores = true 39 | warn_redundant_casts = true 40 | show_error_codes = true 41 | disallow_untyped_defs = true 42 | ignore_missing_imports = true 43 | follow_imports = "silent" 44 | 45 | [[tool.mypy.overrides]] 46 | module = "tests.*" 47 | disallow_untyped_defs = false 48 | check_untyped_defs = true 49 | 50 | [tool.ruff] 51 | line-length = 120 52 | 53 | [tool.ruff.lint] 54 | select = ["E", "F", "I", "FA", "UP"] 55 | ignore = ["B904", "B028"] 56 | 57 | [tool.ruff.lint.isort] 58 | combine-as-imports = true 59 | 60 | [tool.pytest.ini_options] 61 | addopts = ["--strict-config", "--strict-markers"] 62 | filterwarnings = ["error", "ignore::DeprecationWarning"] 63 | 64 | [tool.coverage.run] 65 | source = ["uvicorn_worker", "tests"] 66 | parallel = true 67 | 68 | [tool.coverage.report] 69 | show_missing = true 70 | skip_covered = true 71 | fail_under = 100 72 | exclude_lines = [ 73 | "pragma: no cover", 74 | "pragma: nocover", 75 | "if TYPE_CHECKING:", 76 | "if typing.TYPE_CHECKING:", 77 | "raise NotImplementedError", 78 | ] 79 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Packaging 4 | build==1.2.2.post1 5 | twine==6.0.1 6 | 7 | # Testing 8 | ruff==0.3.4 9 | mypy==1.9.0 10 | pytest==8.0.0 11 | pytest-xdist==3.5.0 12 | coverage==7.4.1 13 | coverage_enable_subprocess==1.0 14 | httpx==0.27.0 15 | trustme==1.1.0 16 | cryptography==42.0.4 17 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ -d 'venv' ] ; then 4 | PREFIX="venv/bin/" 5 | else 6 | PREFIX="" 7 | fi 8 | 9 | set -x 10 | 11 | ${PREFIX}python -m build 12 | ${PREFIX}twine check dist/* 13 | -------------------------------------------------------------------------------- /scripts/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ]; then 5 | export PREFIX="venv/bin/" 6 | export PATH=${PREFIX}:${PATH} 7 | fi 8 | export SOURCE_FILES="uvicorn_worker tests" 9 | 10 | set -x 11 | 12 | ${PREFIX}ruff format --check --diff $SOURCE_FILES 13 | ${PREFIX}mypy $SOURCE_FILES 14 | ${PREFIX}ruff check $SOURCE_FILES 15 | -------------------------------------------------------------------------------- /scripts/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ]; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -x 9 | 10 | ${PREFIX}coverage combine -a -q 11 | ${PREFIX}coverage report 12 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # Use the Python executable provided from the `-p` option, or a default. 4 | [ "$1" = "-p" ] && PYTHON=$2 || PYTHON="python3" 5 | 6 | REQUIREMENTS="requirements.txt" 7 | VENV="venv" 8 | 9 | set -x 10 | 11 | if [ -z "$GITHUB_ACTIONS" ]; then 12 | "$PYTHON" -m venv "$VENV" 13 | PIP="$VENV/bin/pip" 14 | else 15 | PIP="$PYTHON -m pip" 16 | fi 17 | 18 | ${PIP} install -U pip 19 | ${PIP} install -r "$REQUIREMENTS" 20 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ]; then 5 | export PREFIX="venv/bin/" 6 | export PATH=${PREFIX}:${PATH} 7 | fi 8 | export SOURCE_FILES="uvicorn_worker tests" 9 | 10 | set -x 11 | 12 | ${PREFIX}ruff format $SOURCE_FILES 13 | ${PREFIX}ruff check --fix $SOURCE_FILES 14 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ]; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -ex 9 | 10 | if [ -z $GITHUB_ACTIONS ]; then 11 | scripts/check 12 | fi 13 | 14 | # enable subprocess coverage 15 | # https://coverage.readthedocs.io/en/latest/subprocess.html 16 | # https://github.com/nedbat/coveragepy/issues/367 17 | export COVERAGE_PROCESS_START=$PWD/pyproject.toml 18 | ${PREFIX}coverage run --debug config -m pytest "$@" -n auto 19 | 20 | if [ -z $GITHUB_ACTIONS ]; then 21 | scripts/coverage 22 | fi 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-worker/e1c676123402fd219b1497fb09bd969aa7c428ec/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import socket 5 | import ssl 6 | 7 | import pytest 8 | import trustme 9 | from cryptography.hazmat.backends import default_backend 10 | from cryptography.hazmat.primitives import serialization 11 | from uvicorn.config import LOGGING_CONFIG 12 | 13 | # Note: We explicitly turn the propagate on just for tests, because pytest 14 | # caplog not able to capture no-propagate loggers. 15 | # 16 | # And the caplog_for_logger helper also not work on test config cases, because 17 | # when create Config object, Config.configure_logging will remove caplog.handler. 18 | # 19 | # The simple solution is set propagate=True before execute tests. 20 | # 21 | # See also: https://github.com/pytest-dev/pytest/issues/3697 22 | LOGGING_CONFIG["loggers"]["uvicorn"]["propagate"] = True 23 | 24 | 25 | @pytest.fixture 26 | def tls_certificate_authority() -> trustme.CA: 27 | return trustme.CA() 28 | 29 | 30 | @pytest.fixture 31 | def tls_certificate(tls_certificate_authority: trustme.CA) -> trustme.LeafCert: 32 | return tls_certificate_authority.issue_cert( 33 | "localhost", 34 | "127.0.0.1", 35 | "::1", 36 | ) 37 | 38 | 39 | @pytest.fixture 40 | def tls_ca_certificate_pem_path(tls_certificate_authority: trustme.CA): 41 | with tls_certificate_authority.cert_pem.tempfile() as ca_cert_pem: 42 | yield ca_cert_pem 43 | 44 | 45 | @pytest.fixture 46 | def tls_ca_certificate_private_key_path(tls_certificate_authority: trustme.CA): # pragma: no cover 47 | with tls_certificate_authority.private_key_pem.tempfile() as private_key: 48 | yield private_key 49 | 50 | 51 | @pytest.fixture 52 | def tls_certificate_private_key_encrypted_path(tls_certificate: trustme.LeafCert): # pragma: no cover 53 | private_key = serialization.load_pem_private_key( 54 | tls_certificate.private_key_pem.bytes(), 55 | password=None, 56 | backend=default_backend(), 57 | ) 58 | encrypted_key = private_key.private_bytes( 59 | serialization.Encoding.PEM, 60 | serialization.PrivateFormat.TraditionalOpenSSL, 61 | serialization.BestAvailableEncryption(b"uvicorn password for the win"), 62 | ) 63 | with trustme.Blob(encrypted_key).tempfile() as private_encrypted_key: 64 | yield private_encrypted_key 65 | 66 | 67 | @pytest.fixture 68 | def tls_certificate_private_key_path(tls_certificate: trustme.CA): 69 | with tls_certificate.private_key_pem.tempfile() as private_key: 70 | yield private_key 71 | 72 | 73 | @pytest.fixture 74 | def tls_certificate_key_and_chain_path(tls_certificate: trustme.LeafCert): 75 | with tls_certificate.private_key_and_cert_chain_pem.tempfile() as cert_pem: # pragma: no cover 76 | yield cert_pem 77 | 78 | 79 | @pytest.fixture 80 | def tls_certificate_server_cert_path(tls_certificate: trustme.LeafCert): 81 | with tls_certificate.cert_chain_pems[0].tempfile() as cert_pem: 82 | yield cert_pem 83 | 84 | 85 | @pytest.fixture 86 | def tls_ca_ssl_context(tls_certificate_authority: trustme.CA) -> ssl.SSLContext: 87 | ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 88 | tls_certificate_authority.configure_trust(ssl_ctx) 89 | return ssl_ctx 90 | 91 | 92 | def _unused_port(socket_type: int) -> int: 93 | """Find an unused localhost port from 1024-65535 and return it.""" 94 | with contextlib.closing(socket.socket(type=socket_type)) as sock: 95 | sock.bind(("127.0.0.1", 0)) 96 | return sock.getsockname()[1] 97 | 98 | 99 | # This was copied from pytest-asyncio. 100 | # Ref.: https://github.com/pytest-dev/pytest-asyncio/blob/25d9592286682bc6dbfbf291028ff7a9594cf283/pytest_asyncio/plugin.py#L525-L527 # noqa: E501 101 | @pytest.fixture 102 | def unused_tcp_port() -> int: 103 | return _unused_port(socket.SOCK_STREAM) 104 | -------------------------------------------------------------------------------- /tests/test_workers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import signal 4 | import subprocess 5 | import tempfile 6 | import time 7 | from collections.abc import Generator 8 | from ssl import SSLContext 9 | from typing import IO, TYPE_CHECKING 10 | 11 | import httpx 12 | import pytest 13 | from gunicorn.arbiter import Arbiter 14 | 15 | from uvicorn_worker import UvicornH11Worker, UvicornWorker 16 | 17 | if TYPE_CHECKING: 18 | from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, LifespanStartupFailedEvent, Scope 19 | 20 | 21 | class Process(subprocess.Popen): 22 | client: httpx.Client 23 | output: IO[bytes] 24 | 25 | def read_output(self) -> str: 26 | self.output.seek(0) 27 | return self.output.read().decode() 28 | 29 | 30 | @pytest.fixture(params=(UvicornWorker, UvicornH11Worker)) 31 | def worker_class(request: pytest.FixtureRequest) -> str: 32 | """Gunicorn worker class names to test.""" 33 | worker_class = request.param 34 | return f"{worker_class.__module__}.{worker_class.__name__}" 35 | 36 | 37 | async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: # pragma: no cover 38 | assert scope["type"] == "http" 39 | await send({"type": "http.response.start", "status": 204, "headers": []}) 40 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 41 | 42 | 43 | @pytest.fixture( 44 | params=( 45 | pytest.param(False, id="TLS off"), 46 | pytest.param(True, id="TLS on"), 47 | ) 48 | ) 49 | def gunicorn_process( 50 | request: pytest.FixtureRequest, 51 | tls_ca_certificate_pem_path: str, 52 | tls_ca_ssl_context: SSLContext, 53 | tls_certificate_private_key_path: str, 54 | tls_certificate_server_cert_path: str, 55 | unused_tcp_port: int, 56 | worker_class: str, 57 | ) -> Generator[Process, None, None]: 58 | """Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker. 59 | 60 | An instance of `httpx.Client` is available on the `client` attribute. 61 | Output is saved to a temporary file and accessed with `read_output()`. 62 | """ 63 | app = "tests.test_workers:app" 64 | bind = f"127.0.0.1:{unused_tcp_port}" 65 | use_tls: bool = request.param 66 | args = [ 67 | "gunicorn", 68 | "--bind", 69 | bind, 70 | "--graceful-timeout", 71 | "1", 72 | "--log-level", 73 | "debug", 74 | "--worker-class", 75 | worker_class, 76 | "--workers", 77 | "1", 78 | ] 79 | if use_tls is True: 80 | args_for_tls = [ 81 | "--ca-certs", 82 | tls_ca_certificate_pem_path, 83 | "--certfile", 84 | tls_certificate_server_cert_path, 85 | "--keyfile", 86 | tls_certificate_private_key_path, 87 | ] 88 | args.extend(args_for_tls) 89 | base_url = f"https://{bind}" 90 | verify: SSLContext | bool = tls_ca_ssl_context 91 | else: 92 | base_url = f"http://{bind}" 93 | verify = False 94 | args.append(app) 95 | with httpx.Client(base_url=base_url, verify=verify) as client, tempfile.TemporaryFile() as output: 96 | with Process(args, stdout=output, stderr=output) as process: 97 | time.sleep(1.5) 98 | assert not process.poll() 99 | process.client = client 100 | process.output = output 101 | yield process 102 | process.terminate() 103 | process.wait(timeout=2) 104 | 105 | 106 | def test_get_request_to_asgi_app(gunicorn_process: Process) -> None: 107 | """Test a GET request to the Gunicorn Uvicorn worker's ASGI app.""" 108 | response = gunicorn_process.client.get("/") 109 | output_text = gunicorn_process.read_output() 110 | assert response.status_code == 204 111 | assert "uvicorn.workers", "startup complete" in output_text 112 | 113 | 114 | @pytest.mark.parametrize("signal_to_send", Arbiter.SIGNALS) 115 | def test_gunicorn_arbiter_signal_handling(gunicorn_process: Process, signal_to_send: signal.Signals) -> None: 116 | """Test Gunicorn arbiter signal handling. 117 | 118 | This test iterates over the signals handled by the Gunicorn arbiter, 119 | sends each signal to the process running the arbiter, and asserts that 120 | Gunicorn handles the signal and logs the signal handling event accordingly. 121 | 122 | https://docs.gunicorn.org/en/latest/signals.html 123 | """ 124 | signal_abbreviation = Arbiter.SIG_NAMES[signal_to_send] 125 | expected_text = f"Handling signal: {signal_abbreviation}" 126 | gunicorn_process.send_signal(signal_to_send) 127 | time.sleep(0.5) 128 | output_text = gunicorn_process.read_output() 129 | try: 130 | assert expected_text in output_text, output_text 131 | except AssertionError: # pragma: no cover 132 | # occasional flakes are seen with certain signals 133 | flaky_signals = [ 134 | getattr(signal, "SIGHUP", None), 135 | getattr(signal, "SIGTERM", None), 136 | getattr(signal, "SIGTTIN", None), 137 | getattr(signal, "SIGTTOU", None), 138 | getattr(signal, "SIGUSR2", None), 139 | getattr(signal, "SIGWINCH", None), 140 | ] 141 | if signal_to_send not in flaky_signals: 142 | time.sleep(2) 143 | output_text = gunicorn_process.read_output() 144 | assert expected_text in output_text 145 | 146 | 147 | async def app_with_lifespan_startup_failure(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: 148 | """An ASGI app instance for testing Uvicorn worker boot errors.""" 149 | if scope["type"] == "lifespan": 150 | message = await receive() 151 | if message["type"] == "lifespan.startup": 152 | lifespan_startup_failed_event: LifespanStartupFailedEvent = { 153 | "type": "lifespan.startup.failed", 154 | "message": "ASGI application failed to start", 155 | } 156 | await send(lifespan_startup_failed_event) 157 | 158 | 159 | @pytest.fixture 160 | def gunicorn_process_with_lifespan_startup_failure( 161 | unused_tcp_port: int, worker_class: str 162 | ) -> Generator[Process, None, None]: 163 | """Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker. 164 | 165 | Output is saved to a temporary file and accessed with `read_output()`. 166 | The lifespan startup error in the ASGI app helps test worker boot errors. 167 | """ 168 | args = [ 169 | "gunicorn", 170 | "--bind", 171 | f"127.0.0.1:{unused_tcp_port}", 172 | "--graceful-timeout", 173 | "1", 174 | "--log-level", 175 | "debug", 176 | "--worker-class", 177 | worker_class, 178 | "--workers", 179 | "1", 180 | "tests.test_workers:app_with_lifespan_startup_failure", 181 | ] 182 | with tempfile.TemporaryFile() as output: 183 | with Process(args, stdout=output, stderr=output) as process: 184 | time.sleep(1) 185 | process.output = output 186 | yield process 187 | process.terminate() 188 | process.wait(timeout=2) 189 | 190 | 191 | def test_uvicorn_worker_boot_error(gunicorn_process_with_lifespan_startup_failure: Process) -> None: 192 | """Test Gunicorn arbiter shutdown behavior after Uvicorn worker boot errors. 193 | 194 | Previously, if Uvicorn workers raised exceptions during startup, 195 | Gunicorn continued trying to boot workers ([#1066]). To avoid this, 196 | the Uvicorn worker was updated to exit with `Arbiter.WORKER_BOOT_ERROR`, 197 | but no tests were included at that time ([#1077]). This test verifies 198 | that Gunicorn shuts down appropriately after a Uvicorn worker boot error. 199 | 200 | When a worker exits with `Arbiter.WORKER_BOOT_ERROR`, the Gunicorn arbiter will 201 | also terminate, so there is no need to send a separate signal to the arbiter. 202 | 203 | [#1066]: https://github.com/encode/uvicorn/issues/1066 204 | [#1077]: https://github.com/encode/uvicorn/pull/1077 205 | """ 206 | expected_text = "Worker failed to boot" 207 | output_text = gunicorn_process_with_lifespan_startup_failure.read_output() 208 | try: 209 | assert expected_text in output_text 210 | assert gunicorn_process_with_lifespan_startup_failure.poll() # pragma: no cover 211 | except AssertionError: # pragma: no cover 212 | time.sleep(2) 213 | output_text = gunicorn_process_with_lifespan_startup_failure.read_output() 214 | assert expected_text in output_text 215 | assert gunicorn_process_with_lifespan_startup_failure.poll() 216 | -------------------------------------------------------------------------------- /uvicorn_worker/__init__.py: -------------------------------------------------------------------------------- 1 | from uvicorn_worker._workers import UvicornH11Worker, UvicornWorker 2 | 3 | __all__ = ["UvicornH11Worker", "UvicornWorker"] 4 | __version__ = "0.3.0" 5 | -------------------------------------------------------------------------------- /uvicorn_worker/_workers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/). 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | """ 30 | 31 | from __future__ import annotations 32 | 33 | import asyncio 34 | import logging 35 | import signal 36 | import sys 37 | from typing import Any 38 | 39 | from gunicorn.arbiter import Arbiter 40 | from gunicorn.workers.base import Worker 41 | from uvicorn.config import Config 42 | from uvicorn.server import Server 43 | 44 | 45 | class UvicornWorker(Worker): 46 | """ 47 | A worker class for Gunicorn that interfaces with an ASGI consumer callable, 48 | rather than a WSGI callable. 49 | """ 50 | 51 | CONFIG_KWARGS: dict[str, Any] = {"loop": "auto", "http": "auto"} 52 | 53 | def __init__(self, *args: Any, **kwargs: Any) -> None: 54 | super().__init__(*args, **kwargs) 55 | 56 | logger = logging.getLogger("uvicorn.error") 57 | logger.handlers = self.log.error_log.handlers 58 | logger.setLevel(self.log.error_log.level) 59 | logger.propagate = False 60 | 61 | logger = logging.getLogger("uvicorn.access") 62 | logger.handlers = self.log.access_log.handlers 63 | logger.setLevel(self.log.access_log.level) 64 | logger.propagate = False 65 | 66 | config_kwargs: dict = { 67 | "app": None, 68 | "log_config": None, 69 | "timeout_keep_alive": self.cfg.keepalive, 70 | "timeout_notify": self.timeout, 71 | "callback_notify": self.callback_notify, 72 | "limit_max_requests": self.max_requests, 73 | "forwarded_allow_ips": self.cfg.forwarded_allow_ips, 74 | } 75 | 76 | if self.cfg.is_ssl: 77 | ssl_kwargs = { 78 | "ssl_keyfile": self.cfg.ssl_options.get("keyfile"), 79 | "ssl_certfile": self.cfg.ssl_options.get("certfile"), 80 | "ssl_keyfile_password": self.cfg.ssl_options.get("password"), 81 | "ssl_version": self.cfg.ssl_options.get("ssl_version"), 82 | "ssl_cert_reqs": self.cfg.ssl_options.get("cert_reqs"), 83 | "ssl_ca_certs": self.cfg.ssl_options.get("ca_certs"), 84 | "ssl_ciphers": self.cfg.ssl_options.get("ciphers"), 85 | } 86 | config_kwargs.update(ssl_kwargs) 87 | 88 | if self.cfg.settings["backlog"].value: 89 | config_kwargs["backlog"] = self.cfg.settings["backlog"].value 90 | 91 | config_kwargs.update(self.CONFIG_KWARGS) 92 | 93 | self.config = Config(**config_kwargs) 94 | 95 | def init_process(self) -> None: 96 | self.config.setup_event_loop() 97 | super().init_process() 98 | 99 | def init_signals(self) -> None: 100 | # Reset signals so Gunicorn doesn't swallow subprocess return codes 101 | # other signals are set up by Server.install_signal_handlers() 102 | # See: https://github.com/encode/uvicorn/issues/894 103 | for s in self.SIGNALS: 104 | signal.signal(s, signal.SIG_DFL) 105 | 106 | signal.signal(signal.SIGUSR1, self.handle_usr1) 107 | # Don't let SIGUSR1 disturb active requests by interrupting system calls 108 | signal.siginterrupt(signal.SIGUSR1, False) 109 | 110 | def _install_sigquit_handler(self) -> None: 111 | """Install a SIGQUIT handler on workers. 112 | 113 | - https://github.com/encode/uvicorn/issues/1116 114 | - https://github.com/benoitc/gunicorn/issues/2604 115 | """ 116 | 117 | loop = asyncio.get_running_loop() 118 | loop.add_signal_handler(signal.SIGQUIT, self.handle_exit, signal.SIGQUIT, None) 119 | 120 | async def _serve(self) -> None: 121 | self.config.app = self.wsgi 122 | server = Server(config=self.config) 123 | self._install_sigquit_handler() 124 | await server.serve(sockets=self.sockets) 125 | if not server.started: 126 | sys.exit(Arbiter.WORKER_BOOT_ERROR) 127 | 128 | def run(self) -> None: 129 | return asyncio.run(self._serve()) 130 | 131 | async def callback_notify(self) -> None: # pragma: no cover 132 | self.notify() 133 | 134 | 135 | class UvicornH11Worker(UvicornWorker): 136 | CONFIG_KWARGS = {"loop": "asyncio", "http": "h11"} 137 | --------------------------------------------------------------------------------