boring-http 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
boring_http/http3.py ADDED
@@ -0,0 +1,551 @@
1
+ import asyncio
2
+ import secrets
3
+ from collections.abc import AsyncGenerator, Callable
4
+ from dataclasses import dataclass, field
5
+ from enum import IntEnum
6
+ from typing import Final, final
7
+
8
+ from ssl2 import AsyncTransport, ClientHello, PseudoHeader
9
+
10
+ from boring_http.errors import (
11
+ ProtocolError,
12
+ RemoteDisconnected,
13
+ StreamRefused,
14
+ StreamResetError,
15
+ )
16
+ from boring_http.message import Headers, HTTPVersion, Request, Response
17
+ from boring_http.qpack import Qpack
18
+ from boring_http.quic.connection import TransportParameters
19
+ from boring_http.quic.packet import QUIC_V1, Reader, Writer
20
+ from boring_http.quic.protocol import OriginMemory, QuicClient
21
+ from boring_http.timing import Deadline, Timeout
22
+
23
+
24
+ class FrameType(IntEnum):
25
+ DATA = 0x00
26
+ HEADERS = 0x01
27
+ CANCEL_PUSH = 0x03
28
+ SETTINGS = 0x04
29
+ PUSH_PROMISE = 0x05
30
+ GOAWAY = 0x07
31
+ MAX_PUSH_ID = 0x0D
32
+
33
+
34
+ class StreamType(IntEnum):
35
+ CONTROL = 0x00
36
+ PUSH = 0x01
37
+ QPACK_ENCODER = 0x02
38
+ QPACK_DECODER = 0x03
39
+
40
+
41
+ class Setting(IntEnum):
42
+ QPACK_MAX_TABLE_CAPACITY = 0x01
43
+ MAX_FIELD_SECTION_SIZE = 0x06
44
+ QPACK_BLOCKED_STREAMS = 0x07
45
+ ENABLE_CONNECT_PROTOCOL = 0x08
46
+ H3_DATAGRAM = 0x33
47
+
48
+
49
+ class ErrorCode(IntEnum):
50
+ NO_ERROR = 0x100
51
+ GENERAL_PROTOCOL_ERROR = 0x101
52
+ INTERNAL_ERROR = 0x102
53
+ STREAM_CREATION_ERROR = 0x103
54
+ CLOSED_CRITICAL_STREAM = 0x104
55
+ FRAME_UNEXPECTED = 0x105
56
+ FRAME_ERROR = 0x106
57
+ EXCESSIVE_LOAD = 0x107
58
+ ID_ERROR = 0x108
59
+ SETTINGS_ERROR = 0x109
60
+ MISSING_SETTINGS = 0x10A
61
+ REQUEST_REJECTED = 0x10B
62
+ REQUEST_CANCELLED = 0x10C
63
+ REQUEST_INCOMPLETE = 0x10D
64
+ MESSAGE_ERROR = 0x10E
65
+ CONNECT_ERROR = 0x10F
66
+ VERSION_FALLBACK = 0x110
67
+
68
+
69
+ @final
70
+ @dataclass(frozen=True, slots=True)
71
+ class Http3Profile:
72
+ settings: tuple[tuple[int, int], ...] = (
73
+ (Setting.QPACK_MAX_TABLE_CAPACITY, 65536),
74
+ (Setting.MAX_FIELD_SECTION_SIZE, 262144),
75
+ (Setting.QPACK_BLOCKED_STREAMS, 100),
76
+ (Setting.H3_DATAGRAM, 1),
77
+ )
78
+
79
+ def setting(self, identifier: Setting, default: int) -> int:
80
+ return next(
81
+ (value for key, value in self.settings if key == identifier), default
82
+ )
83
+
84
+ pseudo_header_order: tuple[PseudoHeader, ...] = (
85
+ PseudoHeader.METHOD,
86
+ PseudoHeader.AUTHORITY,
87
+ PseudoHeader.SCHEME,
88
+ PseudoHeader.PATH,
89
+ )
90
+ transport: TransportParameters = field(default_factory=TransportParameters)
91
+ quic_version: int = QUIC_V1
92
+ tls: ClientHello | None = None
93
+ grease: bool = True
94
+
95
+
96
+ DEFAULT: Final = Http3Profile()
97
+ # RFC 8470 section 5.2: the requests a client may send before the handshake, since a server may see them twice
98
+ SAFE_METHODS: Final = frozenset({"GET", "HEAD", "OPTIONS"})
99
+
100
+
101
+ @final
102
+ class StreamFrames:
103
+ def __init__(self, client: QuicClient, stream_id: int, deadline: Deadline) -> None:
104
+ self._client = client
105
+ self._stream_id = stream_id
106
+ self._deadline = deadline
107
+ self._buffer = bytearray()
108
+ self.ended = False
109
+
110
+ async def next(self) -> tuple[int, bytes] | None:
111
+ kind = await self.varint()
112
+ if kind is None:
113
+ return None
114
+ length = await self.varint()
115
+ if length is None:
116
+ raise ProtocolError("HTTP/3 frame ended inside its length")
117
+ return kind, await self._exactly(length)
118
+
119
+ async def varint(self) -> int | None:
120
+ if not await self._fill(1):
121
+ return None
122
+ length = 1 << (self._buffer[0] >> 6)
123
+ if not await self._fill(length):
124
+ raise ProtocolError("HTTP/3 stream ended inside a varint")
125
+ return Reader(await self._exactly(length)).varint()
126
+
127
+ def drain(self) -> bytes:
128
+ data = bytes(self._buffer)
129
+ self._buffer.clear()
130
+ return data
131
+
132
+ async def _exactly(self, length: int) -> bytes:
133
+ if not await self._fill(length):
134
+ raise ProtocolError("HTTP/3 stream ended inside a frame")
135
+ data = bytes(self._buffer[:length])
136
+ del self._buffer[:length]
137
+ return data
138
+
139
+ async def _fill(self, length: int) -> bool:
140
+ while len(self._buffer) < length:
141
+ if self.ended:
142
+ return False
143
+ async with self._deadline.phase("read"):
144
+ piece = await self._client.receive(self._stream_id)
145
+ if not piece:
146
+ self.ended = True
147
+ return len(self._buffer) >= length
148
+ self._buffer += piece
149
+ return True
150
+
151
+
152
+ @final
153
+ class Http3Connection:
154
+ def __init__(
155
+ self,
156
+ quic: QuicClient,
157
+ profile: Http3Profile,
158
+ memory: OriginMemory | None = None,
159
+ ) -> None:
160
+ self._quic = quic
161
+ self._profile = profile
162
+ self._qpack = Qpack(profile.setting(Setting.QPACK_MAX_TABLE_CAPACITY, 0))
163
+ self._decoder_stream = -1
164
+ self._peer_settings: dict[int, int] = {}
165
+ self._memory = memory
166
+ self._goaway = False
167
+ self._pending: set[int] = set()
168
+ self._readers: set[asyncio.Task[None]] = set()
169
+ self._settings_received: asyncio.Future[None] = (
170
+ asyncio.get_running_loop().create_future()
171
+ )
172
+ self.closed = False
173
+ self.reused = False
174
+ self.released: Callable[[], None] = self._nothing
175
+
176
+ @property
177
+ def available(self) -> bool:
178
+ return (
179
+ not self.closed
180
+ and not self._goaway
181
+ and not self._quic.closed
182
+ and self._quic.connection.can_open_stream()
183
+ )
184
+
185
+ @property
186
+ def shared(self) -> bool:
187
+ return True
188
+
189
+ @property
190
+ def connect_protocol(self) -> bool:
191
+ return self._peer_settings.get(Setting.ENABLE_CONNECT_PROTOCOL) == 1
192
+
193
+ def take(self) -> None:
194
+ pass
195
+
196
+ async def start(self) -> None:
197
+ control = self._quic.open_stream(bidirectional=False)
198
+ writer = Writer()
199
+ writer.varint(StreamType.CONTROL)
200
+ settings = Writer()
201
+ entries = list(self._profile.settings)
202
+ if self._profile.grease:
203
+ # RFC 9114 section 7.2.4.1: a reserved identifier the peer must ignore, placed at random like Chrome does
204
+ entries.insert(
205
+ secrets.randbelow(len(entries) + 1),
206
+ (0x1F * secrets.randbelow(1 << 16) + 0x21, secrets.randbelow(1 << 20)),
207
+ )
208
+ for identifier, value in entries:
209
+ settings.varint(identifier)
210
+ settings.varint(value)
211
+ writer.varint(FrameType.SETTINGS)
212
+ writer.varint(len(settings))
213
+ writer.put(settings.finish())
214
+ self._quic.send(control, writer.finish())
215
+ self._quic.send(
216
+ self._quic.open_stream(bidirectional=False),
217
+ Writer.encode_varint(StreamType.QPACK_ENCODER),
218
+ )
219
+ self._decoder_stream = self._quic.open_stream(bidirectional=False)
220
+ self._quic.send(
221
+ self._decoder_stream, Writer.encode_varint(StreamType.QPACK_DECODER)
222
+ )
223
+ self._readers.add(asyncio.create_task(self._read_peer_streams()))
224
+ if (
225
+ self._quic.connection.early_data_pending
226
+ and self._memory is not None
227
+ and self._memory.settings
228
+ ):
229
+ # RFC 9114 section 7.2.4.2: 0-RTT requests run under the settings the server sent last time
230
+ self._peer_settings = dict(self._memory.settings)
231
+ return
232
+ _ = await asyncio.wait(
233
+ {self._settings_received, self._quic.terminated},
234
+ return_when=asyncio.FIRST_COMPLETED,
235
+ )
236
+ if not self._settings_received.done():
237
+ terminated = self._quic.terminated.result()
238
+ raise RemoteDisconnected(
239
+ f"QUIC connection terminated before the HTTP/3 SETTINGS frame: {terminated.reason or hex(terminated.error)}"
240
+ )
241
+ await self._settings_received
242
+
243
+ async def send(
244
+ self, request: Request, deadline: Deadline, *, decode: bool
245
+ ) -> Response:
246
+ if request.method not in SAFE_METHODS:
247
+ await self._established(deadline)
248
+ early = self._quic.connection.early_data_pending
249
+ stream_id = self._quic.open_stream()
250
+ block = self._qpack.encode(self._fields(request))
251
+ self._quic.send(
252
+ stream_id, self._frame(FrameType.HEADERS, block), end=request.body is None
253
+ )
254
+ if request.body is not None:
255
+ async with deadline.phase("write"):
256
+ async for chunk in request.body.chunks():
257
+ self._quic.send(stream_id, self._frame(FrameType.DATA, chunk))
258
+ self._quic.send(stream_id, b"", end=True)
259
+ frames = StreamFrames(self._quic, stream_id, deadline)
260
+ status, headers = await self._response_head(frames, stream_id)
261
+ trailers = Headers()
262
+ if (
263
+ status == 425
264
+ and early
265
+ and (request.body is None or request.body.replayable)
266
+ ):
267
+ # RFC 8470 section 5.2: the server wants this request again once the handshake has finished
268
+ await self._established(deadline)
269
+ async for _ in self._body(frames, stream_id, trailers):
270
+ pass
271
+ return await self.send(request, deadline, decode=decode)
272
+ return Response(
273
+ status=status,
274
+ reason="",
275
+ version=HTTPVersion.HTTP_3,
276
+ headers=headers,
277
+ request=request,
278
+ raw=self._body(frames, stream_id, trailers),
279
+ trailers=trailers,
280
+ decode=decode,
281
+ )
282
+
283
+ async def tunnel(
284
+ self, request: Request, deadline: Deadline
285
+ ) -> tuple[int, Headers, AsyncTransport]:
286
+ await self._established(deadline)
287
+ stream_id = self._quic.open_stream()
288
+ self._quic.send(
289
+ stream_id,
290
+ self._frame(
291
+ FrameType.HEADERS,
292
+ self._qpack.encode(self._fields(request, protocol=b"websocket")),
293
+ ),
294
+ )
295
+ frames = StreamFrames(self._quic, stream_id, deadline)
296
+ status, headers = await self._response_head(frames, stream_id)
297
+ return (
298
+ status,
299
+ headers,
300
+ Http3StreamTransport(self._quic, stream_id, deadline, frames),
301
+ )
302
+
303
+ async def close(self) -> None:
304
+ if self.closed:
305
+ return
306
+ self.closed = True
307
+ for reader in self._readers:
308
+ _ = reader.cancel()
309
+ self._quic.close(ErrorCode.NO_ERROR)
310
+ self.released()
311
+
312
+ async def _established(self, deadline: Deadline) -> None:
313
+ if self._quic.connection.early_data_pending:
314
+ async with deadline.phase("connect"):
315
+ await self._quic.established()
316
+
317
+ def _fields(
318
+ self, request: Request, *, protocol: bytes | None = None
319
+ ) -> list[tuple[bytes, bytes]]:
320
+ wire = request.wire_headers(HTTPVersion.HTTP_3)
321
+ authority = request.headers.get("host") or request.url.authority
322
+ scheme = {"ws": "http", "wss": "https"}.get(
323
+ request.url.scheme, request.url.scheme
324
+ )
325
+ pseudo = {
326
+ PseudoHeader.METHOD: (b":method", request.method.encode()),
327
+ PseudoHeader.AUTHORITY: (b":authority", authority.encode()),
328
+ PseudoHeader.SCHEME: (b":scheme", scheme.encode()),
329
+ PseudoHeader.PATH: (b":path", request.url.target.encode()),
330
+ }
331
+ fields = [pseudo[header] for header in self._profile.pseudo_header_order]
332
+ if protocol is not None:
333
+ fields.insert(1, (b":protocol", protocol))
334
+ return fields + [(name.encode(), value.encode()) for name, value in wire]
335
+
336
+ async def _response_head(
337
+ self, frames: StreamFrames, stream_id: int
338
+ ) -> tuple[int, Headers]:
339
+ self._pending.add(stream_id)
340
+ try:
341
+ return await self._head_frames(frames, stream_id)
342
+ except BaseException as error:
343
+ self._qpack.cancel(stream_id)
344
+ self._flush_decoder()
345
+ if (
346
+ isinstance(error, StreamResetError)
347
+ and error.code == ErrorCode.REQUEST_REJECTED
348
+ ):
349
+ raise StreamRefused(
350
+ f"HTTP/3 stream {stream_id} was rejected before processing"
351
+ ) from error
352
+ raise
353
+ finally:
354
+ self._pending.discard(stream_id)
355
+
356
+ async def _head_frames(
357
+ self, frames: StreamFrames, stream_id: int
358
+ ) -> tuple[int, Headers]:
359
+ while True:
360
+ frame = await frames.next()
361
+ if frame is None:
362
+ raise RemoteDisconnected(
363
+ f"HTTP/3 stream {stream_id} ended before the response head"
364
+ )
365
+ kind, payload = frame
366
+ if kind == FrameType.HEADERS:
367
+ decoded = await self._decode(payload, stream_id)
368
+ status = next(
369
+ (value for name, value in decoded if name == ":status"), ""
370
+ )
371
+ if not (status.isascii() and status.isdigit()):
372
+ raise ProtocolError("HTTP/3 response without a numeric :status")
373
+ if 100 <= int(status) < 200:
374
+ continue
375
+ return int(status), Headers(
376
+ [
377
+ (name, value)
378
+ for name, value in decoded
379
+ if not name.startswith(":")
380
+ ]
381
+ )
382
+ if kind in (
383
+ FrameType.PUSH_PROMISE,
384
+ FrameType.SETTINGS,
385
+ FrameType.GOAWAY,
386
+ FrameType.MAX_PUSH_ID,
387
+ FrameType.CANCEL_PUSH,
388
+ ):
389
+ raise ProtocolError(
390
+ f"unexpected HTTP/3 frame {FrameType(kind).name} on a request stream"
391
+ )
392
+
393
+ async def _body(
394
+ self, frames: StreamFrames, stream_id: int, trailers: Headers
395
+ ) -> AsyncGenerator[bytes]:
396
+ try:
397
+ while (frame := await frames.next()) is not None:
398
+ kind, payload = frame
399
+ if kind == FrameType.DATA:
400
+ if payload:
401
+ yield payload
402
+ elif kind == FrameType.HEADERS:
403
+ for name, value in await self._decode(payload, stream_id):
404
+ trailers.add(name, value)
405
+ elif kind in (
406
+ FrameType.PUSH_PROMISE,
407
+ FrameType.SETTINGS,
408
+ FrameType.GOAWAY,
409
+ FrameType.MAX_PUSH_ID,
410
+ FrameType.CANCEL_PUSH,
411
+ ):
412
+ raise ProtocolError(
413
+ f"unexpected HTTP/3 frame {FrameType(kind).name} on a request stream"
414
+ )
415
+ except BaseException:
416
+ self._quic.reset(stream_id, ErrorCode.REQUEST_CANCELLED)
417
+ self._quic.stop_sending(stream_id, ErrorCode.REQUEST_CANCELLED)
418
+ self._qpack.cancel(stream_id)
419
+ self._flush_decoder()
420
+ raise
421
+ finally:
422
+ self.released()
423
+
424
+ async def _decode(self, block: bytes, stream_id: int) -> list[tuple[str, str]]:
425
+ # a block that refers to dynamic table entries still on the encoder stream waits for them
426
+ while (fields := self._qpack.decode(block, stream_id)) is None:
427
+ await self._qpack.wait()
428
+ self._flush_decoder()
429
+ decoded = [
430
+ (name.decode("latin-1"), value.decode("latin-1")) for name, value in fields
431
+ ]
432
+ try:
433
+ _ = Headers(
434
+ [(name, value) for name, value in decoded if not name.startswith(":")]
435
+ )
436
+ except ValueError as error:
437
+ raise ProtocolError(f"malformed header from the server: {error}") from None
438
+ return decoded
439
+
440
+ def _flush_decoder(self) -> None:
441
+ instructions = self._qpack.take_instructions()
442
+ if instructions and not self._quic.closed:
443
+ self._quic.send(self._decoder_stream, instructions)
444
+
445
+ async def _read_peer_streams(self) -> None:
446
+ while True:
447
+ stream_id = await self._quic.incoming.get()
448
+ reader = asyncio.create_task(self._read_peer_stream(stream_id))
449
+ self._readers.add(reader)
450
+ reader.add_done_callback(self._readers.discard)
451
+
452
+ async def _read_peer_stream(self, stream_id: int) -> None:
453
+ if stream_id % 4 != 3:
454
+ self._quic.stop_sending(stream_id, ErrorCode.STREAM_CREATION_ERROR)
455
+ return
456
+ try:
457
+ await self._read_unidirectional(stream_id)
458
+ except ProtocolError as error:
459
+ self._quic.close(ErrorCode.GENERAL_PROTOCOL_ERROR, str(error))
460
+ if not self._settings_received.done():
461
+ self._settings_received.set_exception(error)
462
+ except RemoteDisconnected as error:
463
+ if not self._settings_received.done():
464
+ self._settings_received.set_exception(error)
465
+
466
+ async def _read_unidirectional(self, stream_id: int) -> None:
467
+ frames = StreamFrames(self._quic, stream_id, Deadline(Timeout.of(None)))
468
+ kind = await frames.varint()
469
+ match kind:
470
+ case StreamType.CONTROL:
471
+ while (frame := await frames.next()) is not None:
472
+ frame_type, payload = frame
473
+ if frame_type == FrameType.SETTINGS:
474
+ reader = Reader(payload)
475
+ settings: dict[int, int] = {}
476
+ while reader.remaining:
477
+ identifier = reader.varint()
478
+ settings[identifier] = reader.varint()
479
+ self._peer_settings = settings
480
+ if self._memory is not None:
481
+ self._memory.settings = settings
482
+ if not self._settings_received.done():
483
+ self._settings_received.set_result(None)
484
+ elif frame_type == FrameType.GOAWAY:
485
+ self._goaway = True
486
+ first_unprocessed = Reader(payload).varint()
487
+ for pending in list(self._pending):
488
+ if pending >= first_unprocessed:
489
+ self._quic.fail_stream(
490
+ pending,
491
+ StreamRefused(
492
+ f"HTTP/3 stream {pending} was refused by GOAWAY"
493
+ ),
494
+ )
495
+ case StreamType.QPACK_ENCODER:
496
+ # the stream type was read through the frame buffer, which may already hold the first instructions
497
+ self._qpack.feed_encoder(frames.drain())
498
+ self._flush_decoder()
499
+ while piece := await self._quic.receive(stream_id):
500
+ self._qpack.feed_encoder(piece)
501
+ self._flush_decoder()
502
+ case StreamType.QPACK_DECODER:
503
+ # our encoder never touches the dynamic table, so the peer's acknowledgements carry nothing to act on
504
+ while await self._quic.receive(stream_id):
505
+ pass
506
+ case _:
507
+ self._quic.stop_sending(stream_id, ErrorCode.STREAM_CREATION_ERROR)
508
+
509
+ @staticmethod
510
+ def _frame(kind: FrameType, payload: bytes) -> bytes:
511
+ return Writer.encode_varint(kind) + Writer.encode_varint(len(payload)) + payload
512
+
513
+ @staticmethod
514
+ def _nothing() -> None:
515
+ pass
516
+
517
+
518
+ @final
519
+ class Http3StreamTransport:
520
+ def __init__(
521
+ self, quic: QuicClient, stream_id: int, deadline: Deadline, frames: StreamFrames
522
+ ) -> None:
523
+ self._quic = quic
524
+ self._stream_id = stream_id
525
+ self._frames = frames
526
+ self._deadline = deadline
527
+ self._pending = b""
528
+ self._closed = False
529
+
530
+ async def read(self, limit: int) -> bytes:
531
+ while not self._pending:
532
+ frame = await self._frames.next()
533
+ if frame is None:
534
+ return b""
535
+ if frame[0] == FrameType.DATA:
536
+ self._pending = frame[1]
537
+ piece, self._pending = self._pending[:limit], self._pending[limit:]
538
+ return piece
539
+
540
+ async def write(self, data: bytes) -> None:
541
+ self._quic.send(
542
+ self._stream_id,
543
+ Writer.encode_varint(FrameType.DATA)
544
+ + Writer.encode_varint(len(data))
545
+ + data,
546
+ )
547
+
548
+ async def close(self) -> None:
549
+ if not self._closed:
550
+ self._closed = True
551
+ self._quic.send(self._stream_id, b"", end=True)