HTTP/3 Support (#2378)

This commit is contained in:
Adam Hopkins
2022-06-27 11:19:26 +03:00
committed by GitHub
parent 70382f21ba
commit b59da498cc
72 changed files with 2567 additions and 437 deletions

0
tests/http3/__init__.py Normal file
View File

View File

@@ -0,0 +1,294 @@
from unittest.mock import Mock
import pytest
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import DataReceived, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.connection import QuicConnection
from aioquic.quic.events import ProtocolNegotiated
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.config import DEFAULT_CONFIG
from sanic.exceptions import PayloadTooLarge
from sanic.http.constants import Stage
from sanic.http.http3 import Http3, HTTPReceiver
from sanic.models.server_types import ConnInfo
from sanic.response import empty, json
from sanic.server.protocols.http_protocol import Http3Protocol
try:
from unittest.mock import AsyncMock
except ImportError:
from tests.asyncmock import AsyncMock # type: ignore
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
async def setup(app: Sanic):
@app.get("/")
async def handler(*_):
return empty()
app.router.finalize()
app.signal_router.finalize()
app.signal_router.allow_fail_builtin = False
@pytest.fixture
def http_request(app):
return Request(b"/", Header({}), "3", "GET", Mock(), app)
def generate_protocol(app):
connection = QuicConnection(configuration=QuicConfiguration())
connection._ack_delay = 0
connection._loss = Mock()
connection._loss.spaces = []
connection._loss.get_loss_detection_time = lambda: None
connection.datagrams_to_send = Mock(return_value=[]) # type: ignore
return Http3Protocol(
connection,
app=app,
stream_handler=None,
)
def generate_http_receiver(app, http_request) -> HTTPReceiver:
protocol = generate_protocol(app)
receiver = HTTPReceiver(
protocol.transmit,
protocol,
http_request,
)
http_request.stream = receiver
return receiver
def test_http_receiver_init(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
assert receiver.request_body is None
assert receiver.stage is Stage.IDLE
assert receiver.headers_sent is False
assert receiver.response is None
assert receiver.request_max_size == DEFAULT_CONFIG["REQUEST_MAX_SIZE"]
assert receiver.request_bytes == 0
async def test_http_receiver_run_request(app: Sanic, http_request: Request):
handler = AsyncMock()
class mock_handle(Sanic):
handle_request = handler
app.__class__ = mock_handle
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
await receiver.run()
handler.assert_awaited_once_with(receiver.request)
async def test_http_receiver_run_exception(app: Sanic, http_request: Request):
handler = AsyncMock()
class mock_handle(Sanic):
handle_exception = handler
app.__class__ = mock_handle
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
exception = Exception("Oof")
await receiver.run(exception)
handler.assert_awaited_once_with(receiver.request, exception)
handler.reset_mock()
receiver.stage = Stage.REQUEST
await receiver.run(exception)
handler.assert_awaited_once_with(receiver.request, exception)
def test_http_receiver_respond(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
response = empty()
receiver.stage = Stage.RESPONSE
with pytest.raises(RuntimeError, match="Response already started"):
receiver.respond(response)
receiver.stage = Stage.HANDLER
receiver.response = Mock()
resp = receiver.respond(response)
assert receiver.response is resp
assert resp is response
assert response.stream is receiver
def test_http_receiver_receive_body(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
receiver.request_max_size = 4
receiver.receive_body(b"..")
assert receiver.request.body == b".."
receiver.receive_body(b"..")
assert receiver.request.body == b"...."
with pytest.raises(
PayloadTooLarge, match="Request body exceeds the size limit"
):
receiver.receive_body(b"..")
def test_http3_events(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
http3.http_event_received(DataReceived(b"foobar", 1, False))
receiver = http3.receivers[1]
assert len(http3.receivers) == 1
assert receiver.request.stream_id == 1
assert receiver.request.path == "/location"
assert receiver.request.method == "GET"
assert receiver.request.headers["foo"] == "bar"
assert receiver.request.body == b"foobar"
async def test_send_headers(app: Sanic, http_request: Request):
send_headers_mock = Mock()
existing_send_headers = H3Connection.send_headers
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
http_request._protocol = receiver.protocol
def send_headers(*args, **kwargs):
send_headers_mock(*args, **kwargs)
return existing_send_headers(
receiver.protocol.connection, *args, **kwargs
)
receiver.protocol.connection.send_headers = send_headers
receiver.head_only = False
response = json({}, status=201, headers={"foo": "bar"})
with pytest.raises(RuntimeError, match="no response"):
receiver.send_headers()
receiver.response = response
receiver.send_headers()
assert receiver.headers_sent
assert receiver.stage is Stage.RESPONSE
send_headers_mock.assert_called_once_with(
stream_id=0,
headers=[
(b":status", b"201"),
(b"foo", b"bar"),
(b"content-length", b"2"),
(b"content-type", b"application/json"),
],
)
def test_multiple_streams(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
2,
False,
)
)
receiver1 = http3.get_receiver_by_stream_id(1)
receiver2 = http3.get_receiver_by_stream_id(2)
assert len(http3.receivers) == 2
assert isinstance(receiver1, HTTPReceiver)
assert isinstance(receiver2, HTTPReceiver)
assert receiver1 is not receiver2
def test_request_stream_id(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request, Request)
assert receiver.request.stream_id == 1
def test_request_conn_info(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request.conn_info, ConnInfo)

114
tests/http3/test_server.py Normal file
View File

@@ -0,0 +1,114 @@
import logging
import sys
from asyncio import Event
from pathlib import Path
import pytest
from sanic import Sanic
from sanic.compat import UVLOOP_INSTALLED
from sanic.http.constants import HTTP
parent_dir = Path(__file__).parent.parent
localhost_dir = parent_dir / "certs/localhost"
@pytest.mark.parametrize("version", (3, HTTP.VERSION_3))
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http3(app: Sanic, version, caplog):
ev = Event()
@app.after_server_start
def shutdown(*_):
ev.set()
app.stop()
with caplog.at_level(logging.INFO):
app.run(
version=version,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
assert ev.is_set()
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/3",
) in caplog.record_tuples
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http1_and_http3(app: Sanic, caplog):
@app.after_server_start
def shutdown(*_):
app.stop()
app.prepare(
version=3,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
app.prepare(
version=1,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
with caplog.at_level(logging.INFO):
Sanic.serve()
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/1.1",
) in caplog.record_tuples
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/3",
) in caplog.record_tuples
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http1_and_http3_bad_order(app: Sanic, caplog):
@app.after_server_start
def shutdown(*_):
app.stop()
app.prepare(
version=1,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
message = (
"Serving HTTP/3 instances as a secondary server is not supported. "
"There can only be a single HTTP/3 worker and it must be the first "
"instance prepared."
)
with pytest.raises(RuntimeError, match=message):
app.prepare(
version=3,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)

View File

@@ -0,0 +1,46 @@
from datetime import datetime
from aioquic.tls import CipherSuite, SessionTicket
from sanic.http.http3 import SessionTicketStore
def _generate_ticket(label):
return SessionTicket(
1,
CipherSuite.AES_128_GCM_SHA256,
datetime.now(),
datetime.now(),
label,
label.decode(),
label,
None,
[],
)
def test_session_ticket_store():
store = SessionTicketStore()
assert len(store.tickets) == 0
ticket1 = _generate_ticket(b"foo")
store.add(ticket1)
assert len(store.tickets) == 1
ticket2 = _generate_ticket(b"bar")
store.add(ticket2)
assert len(store.tickets) == 2
assert len(store.tickets) == 2
popped2 = store.pop(ticket2.ticket)
assert len(store.tickets) == 1
assert popped2 is ticket2
popped1 = store.pop(ticket1.ticket)
assert len(store.tickets) == 0
assert popped1 is ticket1