gabriel / musehub public
test_hypercorn_http2.py python
173 lines 5.6 KB
Raw
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