test_hypercorn_http2.py
python
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠ breaking
156 days ago
| 1 | """Tests confirming hypercorn serves HTTP/2 (h2c) without TLS. |
| 2 | |
| 3 | Wall 13: production was running uvicorn (HTTP/1.1 only). Cloudflare's gRPC proxy |
| 4 | requires HTTP/2 end-to-end to origin. hypercorn supports h2c (HTTP/2 cleartext) |
| 5 | which is what nginx grpc_pass sends to the backend. |
| 6 | |
| 7 | Test plan: |
| 8 | A. hypercorn can be imported and is available in the environment |
| 9 | B. hypercorn Config supports h2c (no certfile/keyfile needed) |
| 10 | C. A live hypercorn server on a random port accepts HTTP/2 requests via httpx |
| 11 | D. The push/stream endpoint receives a body over HTTP/2 |
| 12 | """ |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import asyncio |
| 16 | import socket |
| 17 | import threading |
| 18 | import time |
| 19 | import pytest |
| 20 | |
| 21 | |
| 22 | # --------------------------------------------------------------------------- |
| 23 | # A. hypercorn is importable |
| 24 | # --------------------------------------------------------------------------- |
| 25 | |
| 26 | def test_hypercorn_is_importable(): |
| 27 | import hypercorn # noqa: F401 |
| 28 | |
| 29 | |
| 30 | def test_hypercorn_asyncio_serve_is_importable(): |
| 31 | from hypercorn.asyncio import serve # noqa: F401 |
| 32 | |
| 33 | |
| 34 | def test_hypercorn_config_is_importable(): |
| 35 | from hypercorn.config import Config # noqa: F401 |
| 36 | |
| 37 | |
| 38 | # --------------------------------------------------------------------------- |
| 39 | # B. hypercorn Config supports h2c (no TLS required) |
| 40 | # --------------------------------------------------------------------------- |
| 41 | |
| 42 | def test_hypercorn_config_h2c_no_tls(): |
| 43 | from hypercorn.config import Config |
| 44 | cfg = Config() |
| 45 | cfg.bind = ["0.0.0.0:0"] |
| 46 | # No certfile / keyfile — this is h2c (cleartext HTTP/2) |
| 47 | assert cfg.ssl_enabled is False |
| 48 | |
| 49 | |
| 50 | def test_hypercorn_config_h2_in_default_alpn(): |
| 51 | from hypercorn.config import Config |
| 52 | cfg = Config() |
| 53 | # h2 must be in the ALPN protocols hypercorn advertises when TLS is present. |
| 54 | # This ensures TLS mode also supports HTTP/2. |
| 55 | assert "h2" in cfg.alpn_protocols |
| 56 | |
| 57 | |
| 58 | # --------------------------------------------------------------------------- |
| 59 | # C. Live h2c server accepts HTTP/2 via raw h2 socket (nginx grpc_pass style) |
| 60 | # --------------------------------------------------------------------------- |
| 61 | |
| 62 | def _free_port() -> int: |
| 63 | with socket.socket() as s: |
| 64 | s.bind(("127.0.0.1", 0)) |
| 65 | return s.getsockname()[1] |
| 66 | |
| 67 | |
| 68 | def _h2c_get(host: str, port: int, path: str = "/") -> int: |
| 69 | """Send an HTTP/2 h2c GET using the h2 library directly. |
| 70 | |
| 71 | This mirrors what nginx grpc_pass does: open a plain TCP connection, send |
| 72 | the HTTP/2 client preface, send headers, read the response status. |
| 73 | Returns the HTTP status code. |
| 74 | """ |
| 75 | import h2.connection |
| 76 | import h2.config |
| 77 | import h2.events |
| 78 | |
| 79 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 80 | sock.settimeout(5) |
| 81 | sock.connect((host, port)) |
| 82 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) |
| 83 | |
| 84 | cfg = h2.config.H2Configuration(client_side=True, header_encoding="utf-8") |
| 85 | conn = h2.connection.H2Connection(config=cfg) |
| 86 | conn.initiate_connection() |
| 87 | conn.send_headers( |
| 88 | stream_id=1, |
| 89 | headers=[ |
| 90 | (":method", "GET"), |
| 91 | (":path", path), |
| 92 | (":authority", f"{host}:{port}"), |
| 93 | (":scheme", "http"), |
| 94 | ], |
| 95 | end_stream=True, |
| 96 | ) |
| 97 | sock.sendall(conn.data_to_send(65535)) |
| 98 | |
| 99 | status = None |
| 100 | while status is None: |
| 101 | data = sock.recv(65535) |
| 102 | if not data: |
| 103 | break |
| 104 | events = conn.receive_data(data) |
| 105 | for event in events: |
| 106 | if isinstance(event, h2.events.ResponseReceived): |
| 107 | for k, v in event.headers: |
| 108 | if k == ":status": |
| 109 | status = int(v) |
| 110 | sock.sendall(conn.data_to_send(65535)) |
| 111 | |
| 112 | sock.close() |
| 113 | return status or 0 |
| 114 | |
| 115 | |
| 116 | def test_hypercorn_live_h2c_http2(): |
| 117 | """Start a real hypercorn server (no TLS) and confirm it speaks HTTP/2 h2c. |
| 118 | |
| 119 | This test proves the nginx grpc_pass → hypercorn path works: nginx opens a |
| 120 | plain TCP connection and sends the HTTP/2 preface directly (no TLS, no Upgrade). |
| 121 | httpx uses ALPN/TLS for HTTP/2 and cannot test h2c — we use the h2 library. |
| 122 | """ |
| 123 | from hypercorn.asyncio import serve |
| 124 | from hypercorn.config import Config |
| 125 | |
| 126 | async def app(scope, receive, send): |
| 127 | if scope["type"] == "http": |
| 128 | await send({"type": "http.response.start", "status": 200, "headers": []}) |
| 129 | await send({"type": "http.response.body", "body": b"ok", "more_body": False}) |
| 130 | |
| 131 | port = _free_port() |
| 132 | cfg = Config() |
| 133 | cfg.bind = [f"127.0.0.1:{port}"] |
| 134 | cfg.loglevel = "WARNING" |
| 135 | |
| 136 | import multiprocessing as _mp |
| 137 | |
| 138 | def _server_proc(port: int, ready_q): |
| 139 | import asyncio as _asyncio |
| 140 | from hypercorn.asyncio import serve as _serve |
| 141 | from hypercorn.config import Config as _Config |
| 142 | |
| 143 | async def _app(scope, receive, send): |
| 144 | if scope["type"] == "http": |
| 145 | await send({"type": "http.response.start", "status": 200, "headers": []}) |
| 146 | await send({"type": "http.response.body", "body": b"ok", "more_body": False}) |
| 147 | |
| 148 | _cfg = _Config() |
| 149 | _cfg.bind = [f"127.0.0.1:{port}"] |
| 150 | _cfg.loglevel = "WARNING" |
| 151 | |
| 152 | async def _run(): |
| 153 | ready_q.put("ready") |
| 154 | await _serve(_app, _cfg) |
| 155 | |
| 156 | _asyncio.run(_run()) |
| 157 | |
| 158 | ctx = _mp.get_context("fork") |
| 159 | ready_q: _mp.Queue = ctx.Queue() |
| 160 | proc = ctx.Process(target=_server_proc, args=(port, ready_q), daemon=True) |
| 161 | proc.start() |
| 162 | ready_q.get(timeout=10) |
| 163 | time.sleep(0.15) |
| 164 | |
| 165 | try: |
| 166 | status = _h2c_get("127.0.0.1", port) |
| 167 | assert status == 200, ( |
| 168 | f"expected HTTP 200 via h2c but got {status} — " |
| 169 | "hypercorn must accept h2c connections for nginx grpc_pass to work" |
| 170 | ) |
| 171 | finally: |
| 172 | proc.terminate() |
| 173 | proc.join(timeout=3) |
File History
1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠
156 days ago