From d9f92dc25ba20c1eb6490e3de1a3889631d2c4f5 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 12:13:03 -0400 Subject: [PATCH 01/10] Add gunicorn http2 to conformance tests Looks like http2/ASGI has been added to gunicorn (http2 is still in beta), so figured adding these to our conformance suite was reasonable. Ref: https://gunicorn.org/guides/http2 Ref: https://gunicorn.org/asgi Signed-off-by: Stefan VanBuren --- conformance/test/server.py | 26 ++++++++++++++------------ conformance/test/test_server.py | 11 ++++++++++- pyproject.toml | 2 +- uv.lock | 15 ++++++++++----- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/conformance/test/server.py b/conformance/test/server.py index ef39ecc..5e511a5 100644 --- a/conformance/test/server.py +++ b/conformance/test/server.py @@ -543,18 +543,21 @@ async def serve_granian( async def serve_gunicorn( request: ServerCompatRequest, + mode: Mode, certfile: str | None, keyfile: str | None, cafile: str | None, port_future: asyncio.Future[int], ): - args = [ - "--bind=127.0.0.1:0", - "--workers=4", - "--worker-class=gthread", - "--threads=16", - "--keep-alive=0", - ] + args = ["--bind=127.0.0.1:0", "--workers=4"] + if mode == "sync": + args.extend(["--worker-class=gthread", "--threads=16", "--keep-alive=0"]) + else: + args.append("--worker-class=asgi") + # Gunicorn's native ASGI worker supports HTTP/2 only over TLS via ALPN. + # h2c (cleartext HTTP/2) is not supported. + if certfile: + args.append("--http-protocols=h2,h1") if certfile: args.append(f"--certfile={certfile}") if keyfile: @@ -563,7 +566,7 @@ async def serve_gunicorn( args.append(f"--ca-certs={cafile}") args.append(f"--cert-reqs={ssl.CERT_REQUIRED}") - args.append("server:wsgi_app") + args.append("server:wsgi_app" if mode == "sync" else "server:asgi_app") proc = await asyncio.create_subprocess_exec( "gunicorn", @@ -791,11 +794,10 @@ async def main() -> None: ) ) case "gunicorn": - if args.mode == "async": - msg = "gunicorn does not support async mode" - raise ValueError(msg) serve_task = asyncio.create_task( - serve_gunicorn(request, certfile, keyfile, cafile, port_future) + serve_gunicorn( + request, args.mode, certfile, keyfile, cafile, port_future + ) ) case "hypercorn": serve_task = asyncio.create_task( diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index a38ac35..3b271a4 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -77,7 +77,7 @@ def test_server_sync(server: str, cov: Coverage) -> None: pytest.fail(f"\n{result.stdout}\n{result.stderr}") -@pytest.mark.parametrize("server", ["daphne", "pyvoy", "uvicorn"]) +@pytest.mark.parametrize("server", ["daphne", "gunicorn", "pyvoy", "uvicorn"]) def test_server_async(server: str, cov: Coverage) -> None: args = maybe_patch_args_with_debug( [sys.executable, _server_py_path, "--mode", "async", "--server", server] @@ -104,6 +104,15 @@ def test_server_async(server: str, cov: Coverage) -> None: "--skip", "gRPC Unexpected Requests/**", ] + case "gunicorn": + opts = [ + # gunicorn's ASGI worker supports HTTP/2 only over TLS via ALPN; h2c is not supported + "--skip", + "**/HTTPVersion:2/**/TLS:false/**", + # gunicorn doesn't support HTTP/3 + "--skip", + "**/HTTPVersion:3/**", + ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] diff --git a/pyproject.toml b/pyproject.toml index 274b671..fbb038d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ "daphne==4.2.1", "granian==2.7.3", "grpcio-tools==1.80.0", - "gunicorn==25.3.0", + "gunicorn[http2]==25.3.0", "hypercorn==0.18.0", "poethepoet==0.44.0", "pyright[nodejs]==1.1.408", diff --git a/uv.lock b/uv.lock index c5f627d..5f881cd 100644 --- a/uv.lock +++ b/uv.lock @@ -379,7 +379,7 @@ dev = [ { name = "daphne" }, { name = "granian" }, { name = "grpcio-tools" }, - { name = "gunicorn" }, + { name = "gunicorn", extra = ["http2"] }, { name = "hypercorn" }, { name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-wsgi" }, @@ -417,7 +417,7 @@ dev = [ { name = "daphne", specifier = "==4.2.1" }, { name = "granian", specifier = "==2.7.3" }, { name = "grpcio-tools", specifier = "==1.80.0" }, - { name = "gunicorn", specifier = "==25.3.0" }, + { name = "gunicorn", extras = ["http2"], specifier = "==25.3.0" }, { name = "hypercorn", specifier = "==0.18.0" }, { name = "opentelemetry-instrumentation-asgi", specifier = "==0.62b0" }, { name = "opentelemetry-instrumentation-wsgi", specifier = "==0.62b0" }, @@ -683,7 +683,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -962,6 +962,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1956,8 +1961,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ From ac29d655aeb6b024f08a739492507b56ac541711 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 13:47:42 -0400 Subject: [PATCH 02/10] Drop http2 for gunicorn async Signed-off-by: Stefan VanBuren --- conformance/test/server.py | 4 ---- conformance/test/test_server.py | 10 ++-------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/conformance/test/server.py b/conformance/test/server.py index 5e511a5..27f28ae 100644 --- a/conformance/test/server.py +++ b/conformance/test/server.py @@ -554,10 +554,6 @@ async def serve_gunicorn( args.extend(["--worker-class=gthread", "--threads=16", "--keep-alive=0"]) else: args.append("--worker-class=asgi") - # Gunicorn's native ASGI worker supports HTTP/2 only over TLS via ALPN. - # h2c (cleartext HTTP/2) is not supported. - if certfile: - args.append("--http-protocols=h2,h1") if certfile: args.append(f"--certfile={certfile}") if keyfile: diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index 3b271a4..c2d5d99 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -105,14 +105,8 @@ def test_server_async(server: str, cov: Coverage) -> None: "gRPC Unexpected Requests/**", ] case "gunicorn": - opts = [ - # gunicorn's ASGI worker supports HTTP/2 only over TLS via ALPN; h2c is not supported - "--skip", - "**/HTTPVersion:2/**/TLS:false/**", - # gunicorn doesn't support HTTP/3 - "--skip", - "**/HTTPVersion:3/**", - ] + # gunicorn's HTTP/2 support is beta and not yet stable enough for conformance + opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] From b6f24151999cf27354a4e91c7407f27150b10b02 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 14:20:31 -0400 Subject: [PATCH 03/10] Skip more gunicorn tests Signed-off-by: Stefan VanBuren --- conformance/test/test_server.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index c2d5d99..b4adb62 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -105,8 +105,19 @@ def test_server_async(server: str, cov: Coverage) -> None: "gRPC Unexpected Requests/**", ] case "gunicorn": - # gunicorn's HTTP/2 support is beta and not yet stable enough for conformance - opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] + opts = [ + # gunicorn's native HTTP/2 ASGI worker sends GOAWAY after very few streams + # under load; no upstream issue filed yet (see PR #3568 for related h2 fix) + "--skip", + "**/HTTPVersion:2/**", + "--skip", + "**/HTTPVersion:3/**", + # gunicorn's gunicorn_h1c C parser returns 400 "Invalid request line" when + # a client aborts a connection mid-stream; no upstream issue filed yet + # (see issue #3563 for a related gunicorn_h1c strictness regression) + "--skip", + "Errors/**/Protocol:PROTOCOL_GRPC_WEB/**/server-stream/canceled", + ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] From ee8939a01088b4eaf21f3a822f2edcac458fe324 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 14:59:23 -0400 Subject: [PATCH 04/10] Skip whole category Signed-off-by: Stefan VanBuren --- conformance/test/test_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index b4adb62..1323077 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -112,11 +112,11 @@ def test_server_async(server: str, cov: Coverage) -> None: "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**", - # gunicorn's gunicorn_h1c C parser returns 400 "Invalid request line" when - # a client aborts a connection mid-stream; no upstream issue filed yet + # gunicorn's gunicorn_h1c C parser returns 400 "Invalid request line" for + # various gRPC-Web error scenarios; no upstream issue filed yet # (see issue #3563 for a related gunicorn_h1c strictness regression) "--skip", - "Errors/**/Protocol:PROTOCOL_GRPC_WEB/**/server-stream/canceled", + "Errors/**/Protocol:PROTOCOL_GRPC_WEB/**", ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 From 8b1cdb1369f59bb68aad56ec8c554c620313593d Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 15:25:28 -0400 Subject: [PATCH 05/10] Skip grpc-web altogether Signed-off-by: Stefan VanBuren --- conformance/test/test_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index 1323077..b59f18e 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -113,10 +113,10 @@ def test_server_async(server: str, cov: Coverage) -> None: "--skip", "**/HTTPVersion:3/**", # gunicorn's gunicorn_h1c C parser returns 400 "Invalid request line" for - # various gRPC-Web error scenarios; no upstream issue filed yet + # gRPC-Web requests broadly; no upstream issue filed yet # (see issue #3563 for a related gunicorn_h1c strictness regression) "--skip", - "Errors/**/Protocol:PROTOCOL_GRPC_WEB/**", + "**/Protocol:PROTOCOL_GRPC_WEB/**", ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 From 177af37d0d5dddbb8cbf00bc779e206db9831d19 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 16 Apr 2026 16:19:39 -0400 Subject: [PATCH 06/10] Skip more gRPC-web Signed-off-by: Stefan VanBuren --- conformance/test/test_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index b59f18e..045f0c4 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -117,6 +117,10 @@ def test_server_async(server: str, cov: Coverage) -> None: # (see issue #3563 for a related gunicorn_h1c strictness regression) "--skip", "**/Protocol:PROTOCOL_GRPC_WEB/**", + "--skip", + "gRPC-Web Proto Sub-Format Requests/**", + "--skip", + "gRPC-Web Unexpected Requests/**", ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 From 535402e687f5fe3d319f209068c3cfe5843d2e97 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 17 Apr 2026 09:56:04 -0400 Subject: [PATCH 07/10] Drop unused extra and update README.md Signed-off-by: Stefan VanBuren --- README.md | 2 +- pyproject.toml | 2 +- uv.lock | 9 ++------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e19127d..8a72482 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ For ASGI servers: For WSGI servers: -- [Gunicorn](https://gunicorn.org/) - Python WSGI HTTP Server +- [Gunicorn](https://gunicorn.org/) - Python WSGI and ASGI HTTP Server - [uWSGI](https://uwsgi-docs.readthedocs.io/) - Full-featured application server - Any WSGI-compliant server diff --git a/pyproject.toml b/pyproject.toml index fbb038d..274b671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ "daphne==4.2.1", "granian==2.7.3", "grpcio-tools==1.80.0", - "gunicorn[http2]==25.3.0", + "gunicorn==25.3.0", "hypercorn==0.18.0", "poethepoet==0.44.0", "pyright[nodejs]==1.1.408", diff --git a/uv.lock b/uv.lock index 5f881cd..2e928e2 100644 --- a/uv.lock +++ b/uv.lock @@ -379,7 +379,7 @@ dev = [ { name = "daphne" }, { name = "granian" }, { name = "grpcio-tools" }, - { name = "gunicorn", extra = ["http2"] }, + { name = "gunicorn" }, { name = "hypercorn" }, { name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-wsgi" }, @@ -417,7 +417,7 @@ dev = [ { name = "daphne", specifier = "==4.2.1" }, { name = "granian", specifier = "==2.7.3" }, { name = "grpcio-tools", specifier = "==1.80.0" }, - { name = "gunicorn", extras = ["http2"], specifier = "==25.3.0" }, + { name = "gunicorn", specifier = "==25.3.0" }, { name = "hypercorn", specifier = "==0.18.0" }, { name = "opentelemetry-instrumentation-asgi", specifier = "==0.62b0" }, { name = "opentelemetry-instrumentation-wsgi", specifier = "==0.62b0" }, @@ -962,11 +962,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, ] -[package.optional-dependencies] -http2 = [ - { name = "h2" }, -] - [[package]] name = "h11" version = "0.16.0" From 8fda86e0fee3e04bfcb1130d8750223e16f0f061 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 17 Apr 2026 10:01:16 -0400 Subject: [PATCH 08/10] Consolidate skips Signed-off-by: Stefan VanBuren --- conformance/test/test_server.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/conformance/test/test_server.py b/conformance/test/test_server.py index 045f0c4..0d66c43 100644 --- a/conformance/test/test_server.py +++ b/conformance/test/test_server.py @@ -42,6 +42,8 @@ def macos_raise_ulimit(): "Server Message Size/HTTPVersion:1/**/first-request-exceeds-server-limit", ] +_skip_http2_http3 = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] + @pytest.mark.parametrize("server", ["gunicorn", "pyvoy"]) def test_server_sync(server: str, cov: Coverage) -> None: @@ -52,7 +54,7 @@ def test_server_sync(server: str, cov: Coverage) -> None: match server: case "gunicorn": # gunicorn doesn't support HTTP/2 or 3 - opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] + opts = _skip_http2_http3 result = subprocess.run( [ @@ -108,10 +110,7 @@ def test_server_async(server: str, cov: Coverage) -> None: opts = [ # gunicorn's native HTTP/2 ASGI worker sends GOAWAY after very few streams # under load; no upstream issue filed yet (see PR #3568 for related h2 fix) - "--skip", - "**/HTTPVersion:2/**", - "--skip", - "**/HTTPVersion:3/**", + *_skip_http2_http3, # gunicorn's gunicorn_h1c C parser returns 400 "Invalid request line" for # gRPC-Web requests broadly; no upstream issue filed yet # (see issue #3563 for a related gunicorn_h1c strictness regression) @@ -124,7 +123,7 @@ def test_server_async(server: str, cov: Coverage) -> None: ] case "uvicorn": # uvicorn doesn't support HTTP/2 or 3 - opts = ["--skip", "**/HTTPVersion:2/**", "--skip", "**/HTTPVersion:3/**"] + opts = _skip_http2_http3 result = subprocess.run( [ "go", From be546d4c9e88facc5bf41b6591bbc3804dfbd573 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 17 Apr 2026 10:04:32 -0400 Subject: [PATCH 09/10] Add under ASGI as well Not great to have it in both places. Signed-off-by: Stefan VanBuren --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a72482..50f5be0 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ For ASGI servers: - [Uvicorn](https://www.uvicorn.org/) - Lightning-fast ASGI server - [Daphne](https://github.com/django/daphne) - Django Channels' ASGI server with HTTP/2 support - [Hypercorn](https://gitlab.com/pgjones/hypercorn) - ASGI server with HTTP/2 and HTTP/3 support +- [Gunicorn](https://gunicorn.org/) - Python WSGI and ASGI HTTP Server For WSGI servers: From 621364c8aee5371f36aed040d4bb5064af67c473 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 17 Apr 2026 10:05:19 -0400 Subject: [PATCH 10/10] Revert all uv.lock changes Signed-off-by: Stefan VanBuren --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 2e928e2..c5f627d 100644 --- a/uv.lock +++ b/uv.lock @@ -683,7 +683,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1956,8 +1956,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [