from __future__ import annotations
import asyncio
import socket
import ssl
import tempfile
import threading
import httpx
import pytest
from eggfetch.compat.httpx import AsyncClient, Client
from eggfetch.compat.httpx._exceptions import ConnectError, RequestError
from eggfetch.compat.httpx._transports import AsyncHTTPTransport, HTTPTransport
from .native_fixtures import (
_generate_self_signed_cert,
_H2RequestCounter,
_TLSDirectHandler,
_ThreadedHTTPServer,
local_h2_server,
local_proxy_server,
local_tls_h2_server,
)
class TestHttpsH2OnlyAlpn:
@pytest.mark.parametrize("runtime", ["reference", "candidate"])
def test_h2_only_get_health(self, runtime):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
url = f"https://{host}:{port}/health"
if runtime == "reference":
with httpx.Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(url)
else:
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(url)
assert resp.status_code == 200
assert resp.text == "ok"
assert resp.http_version == "HTTP/2"
@pytest.mark.parametrize("runtime", ["reference", "candidate"])
def test_h2_only_get_json(self, runtime):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
url = f"https://{host}:{port}/json"
if runtime == "reference":
with httpx.Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(url)
else:
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(url)
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
assert '"status": "h2-ok"' in resp.text
@pytest.mark.parametrize("runtime", ["reference", "candidate"])
def test_h2_only_multiple_requests_reuse(self, runtime):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, counter):
url = f"https://{host}:{port}/health"
if runtime == "reference":
with httpx.Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
for _ in range(5):
resp = client.get(url)
assert resp.status_code == 200
else:
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
for _ in range(5):
resp = client.get(url)
assert resp.status_code == 200
assert counter.count >= 1
def _make_h1_only_tls_server():
tmpdir = tempfile.mkdtemp()
cert_path, key_path = _generate_self_signed_cert(tmpdir)
server_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ssl.load_cert_chain(cert_path, key_path)
server_ssl.set_alpn_protocols(["http/1.1"])
httpd = _ThreadedHTTPServer(("127.0.0.1", 0), _TLSDirectHandler)
_TLSDirectHandler.recorded_headers = []
raw_socket = httpd.socket
httpd.socket = server_ssl.wrap_socket(raw_socket, server_side=True)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
return "127.0.0.1", port, cert_path, httpd, thread, tmpdir
class TestH2OnlyEnforcement:
def test_reference_httpx_fails_h2_only_vs_h1_only(self):
host, port, cert_path, httpd, thread, tmpdir = _make_h1_only_tls_server()
try:
with httpx.Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
with pytest.raises(httpx.RemoteProtocolError):
client.get(f"https://{host}:{port}/health")
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2)
def test_candidate_h2_only_vs_h1_only_fails(self):
host, port, cert_path, httpd, thread, tmpdir = _make_h1_only_tls_server()
try:
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
with pytest.raises((RequestError, ConnectError)):
client.get(f"https://{host}:{port}/health")
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2)
class TestCleartextH2PriorKnowledge:
def test_reference_httpx_cleartext_h2(self):
with local_h2_server() as (host, port, _counter):
with httpx.Client(
http1=False, http2=True, trust_env=False, timeout=5,
) as client:
resp = client.get(f"http://{host}:{port}/health")
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
def test_candidate_cleartext_h2_prior_knowledge(self):
with local_h2_server() as (host, port, _counter):
with Client(
http1=False, http2=True, trust_env=False, timeout=5,
) as client:
resp = client.get(f"http://{host}:{port}/health")
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
class TestH2OnlyConstructorMatrix:
@pytest.mark.parametrize(
("http1", "http2", "valid"),
[
(True, False, True),
(True, True, True),
(False, True, True),
(False, False, False),
],
)
def test_client_combinations(self, http1, http2, valid):
if valid:
client = Client(http1=http1, http2=http2)
assert client._http1 is http1
assert client._http2 is http2
else:
with pytest.raises(ValueError, match="At least one of http1 or http2"):
Client(http1=http1, http2=http2)
@pytest.mark.parametrize(
("http1", "http2", "valid"),
[
(True, False, True),
(True, True, True),
(False, True, True),
(False, False, False),
],
)
def test_async_client_combinations(self, http1, http2, valid):
if valid:
client = AsyncClient(http1=http1, http2=http2)
assert client._http1 is http1
assert client._http2 is http2
else:
with pytest.raises(ValueError, match="At least one of http1 or http2"):
AsyncClient(http1=http1, http2=http2)
@pytest.mark.parametrize(
("http1", "http2", "valid"),
[
(True, False, True),
(True, True, True),
(False, True, True),
(False, False, False),
],
)
def test_http_transport_combinations(self, http1, http2, valid):
if valid:
transport = HTTPTransport(http1=http1, http2=http2)
assert transport._http1 is http1
assert transport._http2 is http2
else:
with pytest.raises(ValueError, match="At least one of http1 or http2"):
HTTPTransport(http1=http1, http2=http2)
@pytest.mark.parametrize(
("http1", "http2", "valid"),
[
(True, False, True),
(True, True, True),
(False, True, True),
(False, False, False),
],
)
def test_async_http_transport_combinations(self, http1, http2, valid):
if valid:
transport = AsyncHTTPTransport(http1=http1, http2=http2)
assert transport._http1 is http1
assert transport._http2 is http2
else:
with pytest.raises(ValueError, match="At least one of http1 or http2"):
AsyncHTTPTransport(http1=http1, http2=http2)
class TestH2OnlyStreaming:
def test_h2_only_streaming_response(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
with client.stream("GET", f"https://{host}:{port}/streaming") as resp:
assert resp.status_code == 200
chunks = list(resp.iter_text())
assert len(chunks) == 3
assert chunks[0] == "chunk-0\n"
assert chunks[1] == "chunk-1\n"
assert chunks[2] == "chunk-2\n"
def test_h2_only_async_streaming_response(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
async def run():
async with AsyncClient(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
async with client.stream(
"GET", f"https://{host}:{port}/streaming"
) as resp:
assert resp.status_code == 200
chunks = [part async for part in resp.aiter_text()]
assert len(chunks) == 3
assert chunks[0] == "chunk-0\n"
asyncio.run(run())
class TestH2OnlyHttpVersion:
def test_h2_only_reports_http2(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.http_version == "HTTP/2"
def test_h1_only_reports_http1(self):
from .native_fixtures import local_tls_server
with local_tls_server() as (host, port, client_ssl, cert_path):
with Client(
http1=True, http2=False, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.http_version in ("HTTP/1.0", "HTTP/1.1")
def test_auto_reports_version(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
with Client(
http1=True, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.http_version in ("HTTP/1.0", "HTTP/1.1", "HTTP/2")
class TestH2OnlySpecializedRoutes:
def test_h2_only_with_local_address_h2_server(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
transport = HTTPTransport(
http1=False, http2=True, local_address="127.0.0.1",
verify=cert_path,
)
with Client(
transport=transport, trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
def test_h2_only_with_local_address_h1_only_server(self):
host, port, cert_path, httpd, thread, tmpdir = _make_h1_only_tls_server()
try:
transport = HTTPTransport(
http1=False, http2=True, local_address="127.0.0.1",
verify=cert_path,
)
with Client(
transport=transport, trust_env=False, timeout=5,
) as client:
with pytest.raises((RequestError, ConnectError)):
client.get(f"https://{host}:{port}/health")
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2)
def test_h2_only_with_socket_options_h2_server(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
transport = HTTPTransport(
http1=False, http2=True, verify=cert_path,
socket_options=[
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
],
)
with Client(
transport=transport, trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
def test_h2_only_with_socket_options_h1_only_server(self):
host, port, cert_path, httpd, thread, tmpdir = _make_h1_only_tls_server()
try:
transport = HTTPTransport(
http1=False, http2=True, verify=cert_path,
socket_options=[
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
],
)
with Client(
transport=transport, trust_env=False, timeout=5,
) as client:
with pytest.raises((RequestError, ConnectError)):
client.get(f"https://{host}:{port}/health")
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2)
def test_h1_only_with_local_address_h1_server(self):
from .native_fixtures import local_tls_server
with local_tls_server() as (host, port, client_ssl, cert_path):
transport = HTTPTransport(
http1=True, http2=False, local_address="127.0.0.1",
verify=cert_path,
)
with Client(
transport=transport, trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert resp.status_code == 200
def test_h2_only_async_with_local_address_h2_server(self):
async def run():
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
transport = AsyncHTTPTransport(
http1=False, http2=True, local_address="127.0.0.1",
verify=cert_path,
)
async with AsyncClient(
transport=transport, trust_env=False, timeout=5,
) as client:
resp = await client.get(f"https://{host}:{port}/health")
assert resp.status_code == 200
assert resp.http_version == "HTTP/2"
asyncio.run(run())
class TestH2ProxyConnectResidual:
def test_candidate_proxy_connect_remains_http1(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
with local_proxy_server() as (proxy_host, proxy_port, _handler):
with Client(
http1=False,
http2=True,
verify=cert_path,
proxy=f"http://{proxy_host}:{proxy_port}",
trust_env=False,
timeout=5,
) as client:
with pytest.raises((RequestError, ConnectError)):
client.get(f"https://{host}:{port}/health")
class TestH2cWireProof:
def test_h2c_sends_h2_preface(self):
from .native_fixtures import _h2_handle_request
captured = bytearray()
def server_thread(host, port, captured_ref, stop):
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((host, port))
server_sock.listen(8)
server_sock.settimeout(1)
while not stop.is_set():
try:
conn, _ = server_sock.accept()
except (socket.timeout, OSError):
continue
conn.settimeout(2)
try:
first = conn.recv(256)
except (socket.timeout, OSError):
first = b""
captured_ref.extend(first)
try:
conn.close()
except OSError:
pass
host = "127.0.0.1"
stop = threading.Event()
bind_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
bind_sock.bind((host, 0))
port = bind_sock.getsockname()[1]
bind_sock.close()
thread = threading.Thread(
target=server_thread, args=(host, port, captured, stop), daemon=True,
)
thread.start()
try:
with Client(
http1=False, http2=True, trust_env=False, timeout=3,
) as client:
with pytest.raises(Exception):
client.get(f"http://{host}:{port}/health")
finally:
stop.set()
thread.join(timeout=3)
expected_preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
assert bytes(captured).startswith(expected_preface), (
f"Expected H2 preface at start of wire bytes, got: {bytes(captured)!r}"
)
class TestStreamIdAbsence:
def test_stream_id_absent_in_response_extensions(self):
with local_tls_h2_server() as (host, port, _client_ssl, cert_path, _counter):
with Client(
http1=False, http2=True, verify=cert_path,
trust_env=False, timeout=5,
) as client:
resp = client.get(f"https://{host}:{port}/health")
assert "stream_id" not in resp.extensions
class TestH2OnlyNegativeCases:
def test_both_false_raises_value_error(self):
with pytest.raises(ValueError, match="At least one of http1 or http2"):
Client(http1=False, http2=False)
def test_async_both_false_raises_value_error(self):
with pytest.raises(ValueError, match="At least one of http1 or http2"):
AsyncClient(http1=False, http2=False)
def test_transport_both_false_raises_value_error(self):
with pytest.raises(ValueError, match="At least one of http1 or http2"):
HTTPTransport(http1=False, http2=False)
def test_async_transport_both_false_raises_value_error(self):
with pytest.raises(ValueError, match="At least one of http1 or http2"):
AsyncHTTPTransport(http1=False, http2=False)