tigrcorn-protocols 0.3.16.dev5__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.
Files changed (80) hide show
  1. tigrcorn_protocols/__init__.py +1 -0
  2. tigrcorn_protocols/_compression.py +219 -0
  3. tigrcorn_protocols/connect.py +107 -0
  4. tigrcorn_protocols/content_coding.py +179 -0
  5. tigrcorn_protocols/custom/__init__.py +3 -0
  6. tigrcorn_protocols/custom/adapters.py +18 -0
  7. tigrcorn_protocols/custom/registry.py +15 -0
  8. tigrcorn_protocols/flow/__init__.py +1 -0
  9. tigrcorn_protocols/flow/backpressure.py +17 -0
  10. tigrcorn_protocols/flow/buffers.py +29 -0
  11. tigrcorn_protocols/flow/credits.py +21 -0
  12. tigrcorn_protocols/flow/keepalive.py +85 -0
  13. tigrcorn_protocols/flow/timeouts.py +17 -0
  14. tigrcorn_protocols/flow/watermarks.py +16 -0
  15. tigrcorn_protocols/http1/__init__.py +16 -0
  16. tigrcorn_protocols/http1/keepalive.py +21 -0
  17. tigrcorn_protocols/http1/parser.py +481 -0
  18. tigrcorn_protocols/http1/serializer.py +198 -0
  19. tigrcorn_protocols/http1/state.py +9 -0
  20. tigrcorn_protocols/http2/__init__.py +16 -0
  21. tigrcorn_protocols/http2/codec.py +266 -0
  22. tigrcorn_protocols/http2/flow.py +35 -0
  23. tigrcorn_protocols/http2/handler.py +1303 -0
  24. tigrcorn_protocols/http2/hpack.py +393 -0
  25. tigrcorn_protocols/http2/state.py +226 -0
  26. tigrcorn_protocols/http2/streams.py +76 -0
  27. tigrcorn_protocols/http2/websocket.py +360 -0
  28. tigrcorn_protocols/http3/__init__.py +82 -0
  29. tigrcorn_protocols/http3/codec.py +148 -0
  30. tigrcorn_protocols/http3/handler/__init__.py +3 -0
  31. tigrcorn_protocols/http3/handler/core.py +1823 -0
  32. tigrcorn_protocols/http3/handler/webtransport.py +184 -0
  33. tigrcorn_protocols/http3/handler.py +3 -0
  34. tigrcorn_protocols/http3/qpack.py +843 -0
  35. tigrcorn_protocols/http3/state.py +129 -0
  36. tigrcorn_protocols/http3/streams.py +657 -0
  37. tigrcorn_protocols/http3/websocket.py +360 -0
  38. tigrcorn_protocols/lifespan/__init__.py +3 -0
  39. tigrcorn_protocols/lifespan/driver.py +83 -0
  40. tigrcorn_protocols/py.typed +1 -0
  41. tigrcorn_protocols/rawframed/__init__.py +5 -0
  42. tigrcorn_protocols/rawframed/codec.py +18 -0
  43. tigrcorn_protocols/rawframed/frames.py +28 -0
  44. tigrcorn_protocols/rawframed/handler.py +72 -0
  45. tigrcorn_protocols/rawframed/state.py +9 -0
  46. tigrcorn_protocols/registry.py +22 -0
  47. tigrcorn_protocols/scheduler/__init__.py +17 -0
  48. tigrcorn_protocols/scheduler/cancellation.py +40 -0
  49. tigrcorn_protocols/scheduler/dispatch.py +27 -0
  50. tigrcorn_protocols/scheduler/fairness.py +21 -0
  51. tigrcorn_protocols/scheduler/policy.py +12 -0
  52. tigrcorn_protocols/scheduler/priorities.py +8 -0
  53. tigrcorn_protocols/scheduler/quotas.py +19 -0
  54. tigrcorn_protocols/scheduler/runtime.py +156 -0
  55. tigrcorn_protocols/scheduler/tasks.py +31 -0
  56. tigrcorn_protocols/sessions/__init__.py +1 -0
  57. tigrcorn_protocols/sessions/base.py +16 -0
  58. tigrcorn_protocols/sessions/connection.py +12 -0
  59. tigrcorn_protocols/sessions/limits.py +12 -0
  60. tigrcorn_protocols/sessions/manager.py +31 -0
  61. tigrcorn_protocols/sessions/metadata.py +10 -0
  62. tigrcorn_protocols/sessions/quic.py +14 -0
  63. tigrcorn_protocols/streams/__init__.py +1 -0
  64. tigrcorn_protocols/streams/base.py +13 -0
  65. tigrcorn_protocols/streams/ids.py +5 -0
  66. tigrcorn_protocols/streams/multiplex.py +6 -0
  67. tigrcorn_protocols/streams/registry.py +22 -0
  68. tigrcorn_protocols/streams/singleplex.py +6 -0
  69. tigrcorn_protocols/websocket/__init__.py +1 -0
  70. tigrcorn_protocols/websocket/codec.py +31 -0
  71. tigrcorn_protocols/websocket/extensions.py +324 -0
  72. tigrcorn_protocols/websocket/frames.py +174 -0
  73. tigrcorn_protocols/websocket/handler.py +462 -0
  74. tigrcorn_protocols/websocket/handshake.py +66 -0
  75. tigrcorn_protocols/websocket/state.py +10 -0
  76. tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
  77. tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
  78. tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
  79. tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
  80. tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from time import monotonic
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class KeepAlivePolicy:
9
+ idle_timeout: float = 30.0
10
+ ping_interval: float | None = None
11
+ ping_timeout: float | None = None
12
+
13
+ @property
14
+ def effective_ping_interval(self) -> float | None:
15
+ if self.ping_interval is not None:
16
+ return self.ping_interval
17
+ return self.ping_timeout
18
+
19
+ @property
20
+ def effective_ping_timeout(self) -> float | None:
21
+ interval = self.effective_ping_interval
22
+ if self.ping_timeout is not None:
23
+ return self.ping_timeout
24
+ return interval
25
+
26
+ def expired(self, last_activity: float, now: float | None = None) -> bool:
27
+ now = monotonic() if now is None else now
28
+ return now - last_activity >= self.idle_timeout
29
+
30
+ def should_ping(self, last_activity: float, now: float | None = None) -> bool:
31
+ interval = self.effective_ping_interval
32
+ if interval is None:
33
+ return False
34
+ now = monotonic() if now is None else now
35
+ return now - last_activity >= interval
36
+
37
+ def ping_timed_out(self, ping_sent_at: float, now: float | None = None) -> bool:
38
+ timeout = self.effective_ping_timeout
39
+ if timeout is None:
40
+ return False
41
+ now = monotonic() if now is None else now
42
+ return now - ping_sent_at >= timeout
43
+
44
+ @property
45
+ def enabled(self) -> bool:
46
+ return self.effective_ping_interval is not None
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class KeepAliveRuntime:
51
+ policy: KeepAlivePolicy
52
+ last_activity: float = field(default_factory=monotonic)
53
+ pending_ping_payload: bytes | None = None
54
+ pending_ping_sent_at: float | None = None
55
+ sequence: int = 0
56
+
57
+ def record_activity(self, now: float | None = None) -> None:
58
+ self.last_activity = monotonic() if now is None else now
59
+
60
+ def next_ping_payload(self, now: float | None = None) -> bytes | None:
61
+ if self.pending_ping_payload is not None:
62
+ return None
63
+ if not self.policy.should_ping(self.last_activity, now=now):
64
+ return None
65
+ self.sequence += 1
66
+ payload = self.sequence.to_bytes(8, 'big')
67
+ self.pending_ping_payload = payload
68
+ self.pending_ping_sent_at = monotonic() if now is None else now
69
+ return payload
70
+
71
+ def acknowledge_pong(self, payload: bytes, now: float | None = None) -> bool:
72
+ if self.pending_ping_payload is None:
73
+ self.record_activity(now=now)
74
+ return False
75
+ if payload and payload != self.pending_ping_payload:
76
+ return False
77
+ self.pending_ping_payload = None
78
+ self.pending_ping_sent_at = None
79
+ self.record_activity(now=now)
80
+ return True
81
+
82
+ def ping_timed_out(self, now: float | None = None) -> bool:
83
+ if self.pending_ping_sent_at is None:
84
+ return False
85
+ return self.policy.ping_timed_out(self.pending_ping_sent_at, now=now)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class TimeoutPolicy:
10
+ read_timeout: float = 30.0
11
+ write_timeout: float = 30.0
12
+
13
+ async def wait_read(self, awaitable):
14
+ return await asyncio.wait_for(awaitable, timeout=self.read_timeout)
15
+
16
+ async def wait_write(self, awaitable):
17
+ return await asyncio.wait_for(awaitable, timeout=self.write_timeout)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class Watermarks:
8
+ low: int = 16 * 1024
9
+ high: int = 64 * 1024
10
+
11
+ def classify(self, value: int) -> str:
12
+ if value >= self.high:
13
+ return 'high'
14
+ if value <= self.low:
15
+ return 'low'
16
+ return 'mid'
@@ -0,0 +1,16 @@
1
+ from .parser import ParsedRequest, read_http11_request
2
+ from .serializer import (
3
+ finalize_chunked_body,
4
+ serialize_http11_response_chunk,
5
+ serialize_http11_response_head,
6
+ serialize_http11_response_whole,
7
+ )
8
+
9
+ __all__ = [
10
+ "ParsedRequest",
11
+ "read_http11_request",
12
+ "serialize_http11_response_head",
13
+ "serialize_http11_response_whole",
14
+ "serialize_http11_response_chunk",
15
+ "finalize_chunked_body",
16
+ ]
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrcorn_core.utils.headers import get_header, header_contains_token
4
+
5
+
6
+ def keep_alive_for_request(http_version: str, headers: list[tuple[bytes, bytes]]) -> bool:
7
+ if http_version == "1.0":
8
+ return header_contains_token(headers, b"connection", b"keep-alive")
9
+ if header_contains_token(headers, b"connection", b"close"):
10
+ return False
11
+ return True
12
+
13
+
14
+ def expect_continue(headers: list[tuple[bytes, bytes]]) -> bool:
15
+ value = get_header(headers, b"expect")
16
+ return bool(value and value.lower() == b"100-continue")
17
+
18
+
19
+
20
+ def apply_keep_alive_policy(request_keep_alive: bool, *, enabled: bool) -> bool:
21
+ return request_keep_alive and enabled
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+ from urllib.parse import urlsplit
7
+
8
+ from tigrcorn_core.errors import ProtocolError, UnsupportedFeature
9
+ from tigrcorn_protocols.http1.keepalive import expect_continue, keep_alive_for_request
10
+ from tigrcorn_core.types import StreamReaderLike
11
+ from tigrcorn_core.utils.headers import get_headers, header_contains_token
12
+
13
+
14
+ RequestTargetForm = Literal['origin', 'absolute', 'authority', 'asterisk']
15
+
16
+
17
+ _TCHAR = frozenset(b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
18
+
19
+
20
+ def _is_token(value: bytes) -> bool:
21
+ return bool(value) and all(byte in _TCHAR for byte in value)
22
+
23
+
24
+ def _validate_header_name(name: bytes) -> None:
25
+ if not _is_token(name):
26
+ raise ProtocolError('invalid header field name')
27
+
28
+
29
+ def _validate_header_value(value: bytes) -> None:
30
+ for byte in value:
31
+ if byte in {0x00, 0x0A, 0x0D} or (byte < 0x20 and byte != 0x09):
32
+ raise ProtocolError('invalid header field value')
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ParsedRequest:
37
+ method: str
38
+ target: str
39
+ path: str
40
+ raw_path: bytes
41
+ query_string: bytes
42
+ http_version: str
43
+ headers: list[tuple[bytes, bytes]]
44
+ body: bytes
45
+ keep_alive: bool
46
+ expect_continue: bool
47
+ websocket_upgrade: bool
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class ParsedRequestHead:
52
+ method: str
53
+ target: str
54
+ path: str
55
+ raw_path: bytes
56
+ query_string: bytes
57
+ http_version: str
58
+ headers: list[tuple[bytes, bytes]]
59
+ keep_alive: bool
60
+ expect_continue: bool
61
+ websocket_upgrade: bool
62
+ body_kind: Literal['none', 'content-length', 'chunked']
63
+ content_length: int | None
64
+ target_form: RequestTargetForm
65
+
66
+
67
+ async def _read_line(reader: StreamReaderLike) -> bytes:
68
+ try:
69
+ return await reader.readuntil(b"\r\n")
70
+ except asyncio.IncompleteReadError as exc:
71
+ raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
72
+
73
+
74
+ async def _readexactly(reader: StreamReaderLike, amount: int) -> bytes:
75
+ try:
76
+ return await reader.readexactly(amount)
77
+ except asyncio.IncompleteReadError as exc:
78
+ raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
79
+
80
+
81
+ async def _read_request_head_until_terminator(
82
+ reader: StreamReaderLike,
83
+ *,
84
+ limit: int,
85
+ buffer_size: int,
86
+ ) -> bytes:
87
+ limited_readuntil = getattr(reader, 'readuntil_limited', None)
88
+ if callable(limited_readuntil):
89
+ try:
90
+ return await limited_readuntil(b"\r\n\r\n", limit=limit, read_chunk_size=buffer_size)
91
+ except TypeError:
92
+ return await limited_readuntil(b"\r\n\r\n", limit=limit)
93
+ head = await reader.readuntil(b"\r\n\r\n")
94
+ if len(head) > limit:
95
+ raise asyncio.LimitOverrunError('request head exceeds configured HTTP/1.1 request-head limit', consumed=len(head))
96
+ return head
97
+
98
+
99
+ async def _consume_chunked_trailers(reader: StreamReaderLike) -> None:
100
+ while True:
101
+ trailer = await _read_line(reader)
102
+ if trailer == b"\r\n":
103
+ return
104
+ if trailer[:1] in {b' ', b'\t'}:
105
+ raise ProtocolError('obsolete line folding is not supported')
106
+ if b':' not in trailer[:-2]:
107
+ raise ProtocolError('malformed chunk trailer line')
108
+ name, value = trailer[:-2].split(b':', 1)
109
+ _validate_header_name(name.strip().lower())
110
+ _validate_header_value(value.strip())
111
+
112
+
113
+ async def _read_chunked_body(reader: StreamReaderLike, *, max_body_size: int) -> bytes:
114
+ parts: list[bytes] = []
115
+ total = 0
116
+ while True:
117
+ line = await _read_line(reader)
118
+ size_token = line[:-2].split(b';', 1)[0].strip()
119
+ try:
120
+ size = int(size_token, 16)
121
+ except ValueError as exc:
122
+ raise ProtocolError('invalid chunk size') from exc
123
+ if size < 0:
124
+ raise ProtocolError('invalid chunk size')
125
+ if size == 0:
126
+ await _consume_chunked_trailers(reader)
127
+ return b''.join(parts)
128
+ chunk = await _readexactly(reader, size)
129
+ terminator = await _readexactly(reader, 2)
130
+ if terminator != b"\r\n":
131
+ raise ProtocolError('invalid chunk terminator')
132
+ total += size
133
+ if total > max_body_size:
134
+ raise ProtocolError('request body exceeds configured max_body_size')
135
+ parts.append(chunk)
136
+
137
+
138
+
139
+ def _parse_request_target(method: str, target: str) -> tuple[str, bytes, bytes, RequestTargetForm]:
140
+ method_upper = method.upper()
141
+ if target == '*':
142
+ if method_upper != 'OPTIONS':
143
+ raise ProtocolError('asterisk-form request-target is only valid for OPTIONS')
144
+ return '*', b'*', b'', 'asterisk'
145
+
146
+ if method_upper == 'CONNECT':
147
+ if '://' in target or '/' in target or '?' in target or '#' in target or not target:
148
+ raise ProtocolError('invalid authority-form request-target')
149
+ return target, target.encode('ascii'), b'', 'authority'
150
+
151
+ if target.startswith('http://') or target.startswith('https://'):
152
+ split = urlsplit(target)
153
+ if not split.scheme or not split.netloc:
154
+ raise ProtocolError('invalid absolute-form request-target')
155
+ path = split.path or '/'
156
+ return path, path.encode('utf-8'), split.query.encode('ascii'), 'absolute'
157
+
158
+ if not target.startswith('/'):
159
+ raise ProtocolError('invalid origin-form request-target')
160
+ split = urlsplit(target)
161
+ path = split.path or '/'
162
+ return path, path.encode('utf-8'), split.query.encode('ascii'), 'origin'
163
+
164
+
165
+
166
+ def _parse_transfer_encoding(headers: list[tuple[bytes, bytes]]) -> Literal['none', 'chunked']:
167
+ codings: list[bytes] = []
168
+ for key, value in headers:
169
+ if key != b'transfer-encoding':
170
+ continue
171
+ for part in value.split(b','):
172
+ token = part.strip().lower()
173
+ if token:
174
+ codings.append(token)
175
+ if not codings:
176
+ return 'none'
177
+ if codings.count(b'chunked') > 1:
178
+ raise ProtocolError('chunked transfer-encoding must not be repeated')
179
+ if b'chunked' in codings and codings[-1] != b'chunked':
180
+ raise ProtocolError('chunked transfer-encoding must be final')
181
+ unsupported = [coding for coding in codings if coding not in {b'chunked', b'identity'}]
182
+ if unsupported:
183
+ raise UnsupportedFeature('unsupported transfer-encoding')
184
+ if codings and codings[-1] == b'chunked' and any(coding not in {b'chunked', b'identity'} for coding in codings[:-1]):
185
+ raise UnsupportedFeature('unsupported transfer-encoding')
186
+ if any(coding != b'identity' for coding in codings[:-1]):
187
+ raise UnsupportedFeature('unsupported transfer-encoding')
188
+ return 'chunked' if codings[-1] == b'chunked' else 'none'
189
+
190
+
191
+
192
+ def _parse_request_head_bytes(head: bytes) -> ParsedRequestHead | None:
193
+ if not head:
194
+ return None
195
+ lines = head.split(b"\r\n")
196
+ if not lines or not lines[0]:
197
+ return None
198
+
199
+ request_line = lines[0]
200
+ parts = request_line.split(b' ', 2)
201
+ if len(parts) != 3:
202
+ raise ProtocolError('invalid HTTP request line')
203
+
204
+ method_b, target_b, version_b = parts
205
+ if not version_b.startswith(b'HTTP/'):
206
+ raise ProtocolError('invalid HTTP version token')
207
+
208
+ if not _is_token(method_b):
209
+ raise ProtocolError('invalid HTTP method token')
210
+
211
+ try:
212
+ method = method_b.decode('ascii', 'strict')
213
+ target = target_b.decode('ascii', 'strict')
214
+ http_version = version_b.removeprefix(b'HTTP/').decode('ascii', 'strict')
215
+ except UnicodeDecodeError as exc:
216
+ raise ProtocolError('request line is not valid ASCII') from exc
217
+
218
+ if http_version not in {'1.0', '1.1'}:
219
+ raise ProtocolError('unsupported HTTP version')
220
+
221
+ path, raw_path, query_string, target_form = _parse_request_target(method, target)
222
+
223
+ headers: list[tuple[bytes, bytes]] = []
224
+ content_length: int | None = None
225
+ host_values: list[bytes] = []
226
+ for raw_line in lines[1:]:
227
+ if raw_line == b'':
228
+ continue
229
+ if raw_line[:1] in {b' ', b'\t'}:
230
+ raise ProtocolError('obsolete line folding is not supported')
231
+ try:
232
+ key, value = raw_line.split(b':', 1)
233
+ except ValueError as exc:
234
+ raise ProtocolError('malformed header line') from exc
235
+ key = key.strip().lower()
236
+ value = value.strip()
237
+ _validate_header_name(key)
238
+ _validate_header_value(value)
239
+ headers.append((key, value))
240
+ if key == b'content-length':
241
+ try:
242
+ new_len = int(value.decode('ascii'))
243
+ except ValueError as exc:
244
+ raise ProtocolError('invalid Content-Length header') from exc
245
+ if new_len < 0:
246
+ raise ProtocolError('invalid Content-Length header')
247
+ if content_length is None:
248
+ content_length = new_len
249
+ elif content_length != new_len:
250
+ raise ProtocolError('conflicting Content-Length headers')
251
+ elif key == b'host':
252
+ host_values.append(value)
253
+
254
+ if http_version == '1.1':
255
+ if len(host_values) != 1 or not host_values[0]:
256
+ raise ProtocolError('HTTP/1.1 requests must include exactly one Host header')
257
+
258
+ transfer_encoding = _parse_transfer_encoding(headers)
259
+ if transfer_encoding == 'chunked' and content_length is not None:
260
+ raise ProtocolError('request cannot specify both Content-Length and chunked transfer-encoding')
261
+
262
+ body_kind: Literal['none', 'content-length', 'chunked']
263
+ if transfer_encoding == 'chunked':
264
+ body_kind = 'chunked'
265
+ elif content_length:
266
+ body_kind = 'content-length'
267
+ else:
268
+ body_kind = 'none'
269
+
270
+ return ParsedRequestHead(
271
+ method=method,
272
+ target=target,
273
+ path=path,
274
+ raw_path=raw_path,
275
+ query_string=query_string,
276
+ http_version=http_version,
277
+ headers=headers,
278
+ keep_alive=keep_alive_for_request(http_version, headers),
279
+ expect_continue=expect_continue(headers) and body_kind != 'none',
280
+ websocket_upgrade=(
281
+ method.upper() == 'GET'
282
+ and header_contains_token(headers, b'connection', b'upgrade')
283
+ and header_contains_token(headers, b'upgrade', b'websocket')
284
+ ),
285
+ body_kind=body_kind,
286
+ content_length=content_length,
287
+ target_form=target_form,
288
+ )
289
+
290
+
291
+ async def read_http11_request_head(
292
+ reader: StreamReaderLike,
293
+ *,
294
+ max_body_size: int = 16 * 1024 * 1024,
295
+ max_header_size: int = 64 * 1024,
296
+ max_incomplete_event_size: int | None = None,
297
+ buffer_size: int = 64 * 1024,
298
+ ) -> ParsedRequestHead | None:
299
+ request_head_limit = max_header_size if max_incomplete_event_size is None else min(max_header_size, max_incomplete_event_size)
300
+ try:
301
+ head = await _read_request_head_until_terminator(
302
+ reader,
303
+ limit=request_head_limit,
304
+ buffer_size=buffer_size,
305
+ )
306
+ except asyncio.IncompleteReadError as exc:
307
+ if exc.partial == b'':
308
+ return None
309
+ raise ProtocolError('unexpected EOF while reading request head') from exc
310
+ except asyncio.LimitOverrunError as exc:
311
+ raise ProtocolError('request head exceeds configured HTTP/1.1 request-head limit') from exc
312
+
313
+ if not head:
314
+ return None
315
+ if len(head) > request_head_limit:
316
+ raise ProtocolError('request head exceeds configured HTTP/1.1 request-head limit')
317
+ if len(head) > max_header_size:
318
+ raise ProtocolError('request head exceeds configured max_header_size')
319
+
320
+ parsed = _parse_request_head_bytes(head)
321
+ if parsed is None:
322
+ return None
323
+ if parsed.content_length is not None and parsed.content_length > max_body_size:
324
+ raise ProtocolError('request body exceeds configured max_body_size')
325
+ return parsed
326
+
327
+
328
+ HTTP11_REQUEST_HEAD_ERROR_MATRIX: tuple[dict[str, object], ...] = (
329
+ {
330
+ 'case': 'request_line_shape',
331
+ 'rfc': 'RFC 9112 request line',
332
+ 'trigger': 'request line must contain exactly method, target, and version tokens',
333
+ 'expected_exception': 'ProtocolError',
334
+ 'message_fragment': 'invalid HTTP request line',
335
+ },
336
+ {
337
+ 'case': 'http_version_token',
338
+ 'rfc': 'RFC 9112 version token',
339
+ 'trigger': 'version token must begin with HTTP/ and resolve to 1.0 or 1.1',
340
+ 'expected_exception': 'ProtocolError',
341
+ 'message_fragment': 'invalid HTTP version token',
342
+ },
343
+ {
344
+ 'case': 'unsupported_http_version',
345
+ 'rfc': 'RFC 9112 version negotiation',
346
+ 'trigger': 'request line advertises an unsupported HTTP version',
347
+ 'expected_exception': 'ProtocolError',
348
+ 'message_fragment': 'unsupported HTTP version',
349
+ },
350
+ {
351
+ 'case': 'method_token',
352
+ 'rfc': 'RFC 9110 method token syntax',
353
+ 'trigger': 'method token contains invalid bytes',
354
+ 'expected_exception': 'ProtocolError',
355
+ 'message_fragment': 'invalid HTTP method token',
356
+ },
357
+ {
358
+ 'case': 'target_form_authority',
359
+ 'rfc': 'RFC 9112 CONNECT authority-form',
360
+ 'trigger': 'CONNECT target is not valid authority-form',
361
+ 'expected_exception': 'ProtocolError',
362
+ 'message_fragment': 'invalid authority-form request-target',
363
+ },
364
+ {
365
+ 'case': 'target_form_absolute',
366
+ 'rfc': 'RFC 9112 absolute-form',
367
+ 'trigger': 'absolute-form target is syntactically malformed',
368
+ 'expected_exception': 'ProtocolError',
369
+ 'message_fragment': 'invalid absolute-form request-target',
370
+ },
371
+ {
372
+ 'case': 'target_form_origin',
373
+ 'rfc': 'RFC 9112 origin-form',
374
+ 'trigger': 'origin-form target does not start with /',
375
+ 'expected_exception': 'ProtocolError',
376
+ 'message_fragment': 'invalid origin-form request-target',
377
+ },
378
+ {
379
+ 'case': 'target_form_asterisk',
380
+ 'rfc': 'RFC 9112 asterisk-form',
381
+ 'trigger': 'asterisk-form is used with a method other than OPTIONS',
382
+ 'expected_exception': 'ProtocolError',
383
+ 'message_fragment': 'asterisk-form request-target is only valid for OPTIONS',
384
+ },
385
+ {
386
+ 'case': 'header_line_folding',
387
+ 'rfc': 'RFC 9110 field line syntax',
388
+ 'trigger': 'obs-fold / line folding appears in field section',
389
+ 'expected_exception': 'ProtocolError',
390
+ 'message_fragment': 'obsolete line folding is not supported',
391
+ },
392
+ {
393
+ 'case': 'header_name_and_value',
394
+ 'rfc': 'RFC 9110 field syntax',
395
+ 'trigger': 'header field name or value contains forbidden octets',
396
+ 'expected_exception': 'ProtocolError',
397
+ 'message_fragment': 'invalid header field',
398
+ },
399
+ {
400
+ 'case': 'content_length_conflict',
401
+ 'rfc': 'RFC 9112 message body length',
402
+ 'trigger': 'multiple Content-Length values disagree or are negative',
403
+ 'expected_exception': 'ProtocolError',
404
+ 'message_fragment': 'Content-Length',
405
+ },
406
+ {
407
+ 'case': 'host_header_requirements',
408
+ 'rfc': 'RFC 9112 Host requirements',
409
+ 'trigger': 'HTTP/1.1 request does not include exactly one non-empty Host header',
410
+ 'expected_exception': 'ProtocolError',
411
+ 'message_fragment': 'must include exactly one Host header',
412
+ },
413
+ {
414
+ 'case': 'transfer_encoding_chain',
415
+ 'rfc': 'RFC 9112 transfer-coding',
416
+ 'trigger': 'chunked is repeated, not final, or appears with an unsupported chain',
417
+ 'expected_exception': 'ProtocolError|UnsupportedFeature',
418
+ 'message_fragment': 'transfer-encoding',
419
+ },
420
+ {
421
+ 'case': 'content_length_and_chunked_conflict',
422
+ 'rfc': 'RFC 9112 message body length',
423
+ 'trigger': 'Content-Length appears with chunked transfer-encoding',
424
+ 'expected_exception': 'ProtocolError',
425
+ 'message_fragment': 'both Content-Length and chunked transfer-encoding',
426
+ },
427
+ {
428
+ 'case': 'chunked_body_syntax',
429
+ 'rfc': 'RFC 9112 chunked coding',
430
+ 'trigger': 'chunk size, terminator, or trailers are malformed',
431
+ 'expected_exception': 'ProtocolError',
432
+ 'message_fragment': 'chunk',
433
+ },
434
+ {
435
+ 'case': 'size_limits',
436
+ 'rfc': 'RFC 9112 implementation limits',
437
+ 'trigger': 'request head or body exceeds configured limits',
438
+ 'expected_exception': 'ProtocolError',
439
+ 'message_fragment': 'configured max_',
440
+ },
441
+ )
442
+
443
+
444
+ def http11_request_head_error_matrix() -> tuple[dict[str, object], ...]:
445
+ return tuple(dict(entry) for entry in HTTP11_REQUEST_HEAD_ERROR_MATRIX)
446
+
447
+
448
+ async def read_http11_request(
449
+ reader: StreamReaderLike,
450
+ *,
451
+ max_body_size: int = 16 * 1024 * 1024,
452
+ max_header_size: int = 64 * 1024,
453
+ ) -> ParsedRequest | None:
454
+ parsed = await read_http11_request_head(
455
+ reader,
456
+ max_body_size=max_body_size,
457
+ max_header_size=max_header_size,
458
+ )
459
+ if parsed is None:
460
+ return None
461
+
462
+ body = b''
463
+ if parsed.body_kind == 'chunked':
464
+ body = await _read_chunked_body(reader, max_body_size=max_body_size)
465
+ elif parsed.body_kind == 'content-length':
466
+ assert parsed.content_length is not None
467
+ body = await _readexactly(reader, parsed.content_length)
468
+
469
+ return ParsedRequest(
470
+ method=parsed.method,
471
+ target=parsed.target,
472
+ path=parsed.path,
473
+ raw_path=parsed.raw_path,
474
+ query_string=parsed.query_string,
475
+ http_version=parsed.http_version,
476
+ headers=parsed.headers,
477
+ body=body,
478
+ keep_alive=parsed.keep_alive,
479
+ expect_continue=parsed.expect_continue,
480
+ websocket_upgrade=parsed.websocket_upgrade,
481
+ )