wsbuilder 1.2.7__tar.gz → 1.2.9__tar.gz
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.
- {wsbuilder-1.2.7/src/wsbuilder.egg-info → wsbuilder-1.2.9}/PKG-INFO +1 -1
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/data-orm.md +4 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/pyproject.toml +1 -1
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/__init__.py +1 -1
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/app.py +5 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/cache.py +4 -2
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/hpack.py +45 -11
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http.py +10 -1
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http1.py +10 -2
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http2.py +189 -21
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http3_server.py +63 -9
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/orm.py +37 -16
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/qpack.py +11 -6
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/security.py +30 -10
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/server.py +24 -5
- {wsbuilder-1.2.7 → wsbuilder-1.2.9/src/wsbuilder.egg-info}/PKG-INFO +1 -1
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_app_threads.py +28 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_cache.py +7 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_hpack.py +33 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http1.py +20 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http2.py +198 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http3.py +7 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http3_server.py +57 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http3_streaming_udp.py +26 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_orm.py +39 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_security.py +34 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_server.py +93 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/LICENSE +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/MANIFEST.in +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/README.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/404.html +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/CNAME +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/advanced.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/ai-predict.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/api-map.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/app-http.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/architecture.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/assets/wsbuilder-mark.svg +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/beginners.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/cache-security.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/cache.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/capabilities.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/dns.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/full-project.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/getting-started.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/help/index.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/http-versions.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/http.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/ia.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/index.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/install.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/intermediate.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/mdns.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/network-edge.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/observability-tasks.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/observability.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/persistence.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/proxy.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/reference/index.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/security.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/stylesheets/extra.css +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/tasks.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/tls-certificates.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/docs/websocket.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/examples/full-project/.gitignore +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/examples/full-project/README.md +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/examples/full-project/app.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/examples/full-project/pyproject.toml +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/examples/full-project/upstream.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/mkdocs.yml +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/setup.cfg +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/__main__.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/_coerce.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/caches.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/constants.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/cookies.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/db_replicas.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/dns.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/framework.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/headers.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http3.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/http3_websocket.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/ia.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/logs.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/mdns.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/metrics.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/pki.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/predicts.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/proxyi.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/__init__.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/address.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/connection.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/crypto.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/frames.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/packet.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/recovery.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/tls.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/quic/varint.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/tasks.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder/ws.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder.egg-info/SOURCES.txt +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder.egg-info/entry_points.txt +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder.egg-info/requires.txt +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/src/wsbuilder.egg-info/top_level.txt +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_caches.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_contributing_policy.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_demo_monitor.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_dns.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_docs.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_documentation_files.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_headers_cookies.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_http3_streaming.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_ia.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_logs.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_mdns.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_metrics.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_orm_replicas.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_pki.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_pr_identity_policy.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_predicts.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_proxyi.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_proxyi_routing.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_quic_address.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_quic_crypto.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_quic_protocol.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_quic_recovery.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_quic_tls.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_server_errors.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_tasks.py +0 -0
- {wsbuilder-1.2.7 → wsbuilder-1.2.9}/tests/test_ws.py +0 -0
|
@@ -81,6 +81,10 @@ Operadores soportados:
|
|
|
81
81
|
- `field__not_in=[...]`
|
|
82
82
|
- `field__isnull=True`
|
|
83
83
|
|
|
84
|
+
`like` e `ilike` reciben un patron SQL (`%` y `_` son comodines). `contains`,
|
|
85
|
+
`startswith` y `endswith` comparan el valor literal y distinguen mayusculas;
|
|
86
|
+
sus variantes `i*` tambien son literales pero ignoran mayusculas ASCII.
|
|
87
|
+
|
|
84
88
|
Metodos utiles:
|
|
85
89
|
|
|
86
90
|
- `filter`, `exclude`
|
|
@@ -1350,6 +1350,11 @@ class App:
|
|
|
1350
1350
|
if thread_info.port > 0:
|
|
1351
1351
|
resp.headers.setdefault(THREAD_RESPONSE_PORT_HEADER, str(thread_info.port))
|
|
1352
1352
|
resp.headers.setdefault(THREAD_RESPONSE_MODE_HEADER, "worker")
|
|
1353
|
+
# Headers hold one value per name, so a cookie the handler set wins
|
|
1354
|
+
# over the affinity cookie instead of being replaced by it.
|
|
1355
|
+
if thread_info is not None and not any(
|
|
1356
|
+
str(name).lower() == "set-cookie" for name in resp.headers
|
|
1357
|
+
):
|
|
1353
1358
|
signed_cookie = self._sign_thread_cookie(route.path, thread_info.thread_id)
|
|
1354
1359
|
resp.headers["Set-Cookie"] = build_set_cookie(
|
|
1355
1360
|
self.thread_cookie_name,
|
|
@@ -834,8 +834,10 @@ class SQLiteMemoryCache:
|
|
|
834
834
|
sql = "SELECT key, expires_at FROM cache_entries WHERE namespace = ?"
|
|
835
835
|
params = [ns]
|
|
836
836
|
if prefix:
|
|
837
|
-
|
|
838
|
-
|
|
837
|
+
# LIKE treats % and _ as wildcards and ignores ASCII case.
|
|
838
|
+
prefix = str(prefix)
|
|
839
|
+
sql += " AND substr(key, 1, ?) = ?"
|
|
840
|
+
params.extend((len(prefix), prefix))
|
|
839
841
|
if not include_expired:
|
|
840
842
|
sql += " AND (expires_at IS NULL OR expires_at > ?)"
|
|
841
843
|
params.append(time.time())
|
|
@@ -157,6 +157,14 @@ class HPACKError(Exception):
|
|
|
157
157
|
"""A header block that cannot be decoded. Always a connection error."""
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
class HeaderListTooLarge(Exception):
|
|
161
|
+
"""A decoded header list over the caller's limit.
|
|
162
|
+
|
|
163
|
+
Raised only after the whole block was decoded, so the dynamic table is
|
|
164
|
+
still in step with the peer and only the request has to be refused.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
160
168
|
def _build_huffman_tree():
|
|
161
169
|
"""Bit-trie for decoding: nested lists indexed by bit."""
|
|
162
170
|
root = [None, None]
|
|
@@ -292,16 +300,20 @@ def decode_string(data, offset):
|
|
|
292
300
|
raw = bytes(data[offset:end])
|
|
293
301
|
if huffman:
|
|
294
302
|
raw = huffman_decode(raw)
|
|
295
|
-
|
|
303
|
+
# Field values may carry obs-text (RFC 9110 section 5.5); ISO-8859-1 maps
|
|
304
|
+
# every octet, as the HTTP/1 parser does.
|
|
305
|
+
return raw.decode("iso-8859-1"), end
|
|
296
306
|
|
|
297
307
|
|
|
298
308
|
class DynamicTable:
|
|
299
309
|
"""The half of the header table that both peers mutate in step."""
|
|
300
310
|
|
|
301
|
-
def __init__(self, max_size=DEFAULT_DYNAMIC_TABLE_SIZE):
|
|
311
|
+
def __init__(self, max_size=DEFAULT_DYNAMIC_TABLE_SIZE, encoding="utf-8"):
|
|
302
312
|
self._entries = []
|
|
303
313
|
self._size = 0
|
|
304
314
|
self._max_size = int(max_size)
|
|
315
|
+
#: How the entries' strings map to octets, which is what size counts.
|
|
316
|
+
self.encoding = encoding
|
|
305
317
|
|
|
306
318
|
def __len__(self):
|
|
307
319
|
return len(self._entries)
|
|
@@ -319,16 +331,16 @@ class DynamicTable:
|
|
|
319
331
|
self._evict()
|
|
320
332
|
|
|
321
333
|
@staticmethod
|
|
322
|
-
def entry_size(name, value):
|
|
323
|
-
return len(name.encode(
|
|
334
|
+
def entry_size(name, value, encoding="utf-8"):
|
|
335
|
+
return len(name.encode(encoding)) + len(value.encode(encoding)) + ENTRY_OVERHEAD
|
|
324
336
|
|
|
325
337
|
def _evict(self):
|
|
326
338
|
while self._size > self._max_size and self._entries:
|
|
327
339
|
name, value = self._entries.pop()
|
|
328
|
-
self._size -= self.entry_size(name, value)
|
|
340
|
+
self._size -= self.entry_size(name, value, self.encoding)
|
|
329
341
|
|
|
330
342
|
def add(self, name, value):
|
|
331
|
-
cost = self.entry_size(name, value)
|
|
343
|
+
cost = self.entry_size(name, value, self.encoding)
|
|
332
344
|
if cost > self._max_size:
|
|
333
345
|
# Section 4.4: an oversized insert empties the table instead.
|
|
334
346
|
self._entries.clear()
|
|
@@ -373,7 +385,7 @@ class Decoder:
|
|
|
373
385
|
"""Decodes header blocks, keeping the dynamic table in step with the peer."""
|
|
374
386
|
|
|
375
387
|
def __init__(self, max_size=DEFAULT_DYNAMIC_TABLE_SIZE):
|
|
376
|
-
self.table = DynamicTable(max_size)
|
|
388
|
+
self.table = DynamicTable(max_size, encoding="iso-8859-1")
|
|
377
389
|
#: Ceiling the peer may raise the table to, from SETTINGS.
|
|
378
390
|
self.max_allowed_size = int(max_size)
|
|
379
391
|
|
|
@@ -384,21 +396,38 @@ class Decoder:
|
|
|
384
396
|
return STATIC_TABLE[index]
|
|
385
397
|
return self.table.get(index - STATIC_TABLE_SIZE)
|
|
386
398
|
|
|
387
|
-
def decode(self, block):
|
|
399
|
+
def decode(self, block, max_list_size=None):
|
|
400
|
+
"""Decode ``block``; ``max_list_size`` bounds the RFC 7541 list size.
|
|
401
|
+
|
|
402
|
+
Indexed fields can expand a small block into a large list, so past
|
|
403
|
+
the limit fields are no longer kept, decoding still runs to the end
|
|
404
|
+
to keep the table in step, and ``HeaderListTooLarge`` is raised.
|
|
405
|
+
"""
|
|
388
406
|
data = bytes(block)
|
|
389
407
|
offset = 0
|
|
390
408
|
headers = []
|
|
391
409
|
saw_field = False
|
|
410
|
+
list_size = 0
|
|
411
|
+
|
|
412
|
+
def keep(field):
|
|
413
|
+
nonlocal list_size
|
|
414
|
+
if max_list_size is None:
|
|
415
|
+
headers.append(field)
|
|
416
|
+
return
|
|
417
|
+
list_size += DynamicTable.entry_size(*field, encoding=self.table.encoding)
|
|
418
|
+
if list_size <= max_list_size:
|
|
419
|
+
headers.append(field)
|
|
420
|
+
|
|
392
421
|
while offset < len(data):
|
|
393
422
|
byte = data[offset]
|
|
394
423
|
if byte & 0x80:
|
|
395
424
|
index, offset = decode_integer(data, offset, 7)
|
|
396
|
-
|
|
425
|
+
keep(self._lookup(index))
|
|
397
426
|
saw_field = True
|
|
398
427
|
elif byte & 0x40:
|
|
399
428
|
name, value, offset = self._decode_literal(data, offset, 6)
|
|
400
429
|
self.table.add(name, value)
|
|
401
|
-
|
|
430
|
+
keep((name, value))
|
|
402
431
|
saw_field = True
|
|
403
432
|
elif byte & 0x20:
|
|
404
433
|
size, offset = decode_integer(data, offset, 5)
|
|
@@ -413,8 +442,12 @@ class Decoder:
|
|
|
413
442
|
else:
|
|
414
443
|
# 0000 never-indexed and 0001 without-indexing share a shape.
|
|
415
444
|
name, value, offset = self._decode_literal(data, offset, 4)
|
|
416
|
-
|
|
445
|
+
keep((name, value))
|
|
417
446
|
saw_field = True
|
|
447
|
+
if max_list_size is not None and list_size > max_list_size:
|
|
448
|
+
raise HeaderListTooLarge(
|
|
449
|
+
f"header list of {list_size} octets over the {max_list_size} limit"
|
|
450
|
+
)
|
|
418
451
|
return headers
|
|
419
452
|
|
|
420
453
|
def _decode_literal(self, data, offset, prefix_bits):
|
|
@@ -505,6 +538,7 @@ __all__ = [
|
|
|
505
538
|
"Encoder",
|
|
506
539
|
"HPACKError",
|
|
507
540
|
"HUFFMAN_CODES",
|
|
541
|
+
"HeaderListTooLarge",
|
|
508
542
|
"NEVER_INDEXED",
|
|
509
543
|
"STATIC_TABLE",
|
|
510
544
|
"STATIC_TABLE_SIZE",
|
|
@@ -8,6 +8,7 @@ from .logs import get_log
|
|
|
8
8
|
|
|
9
9
|
MAX_QUERY_FIELDS = 1024
|
|
10
10
|
HTTP_0_9 = "HTTP/0.9"
|
|
11
|
+
HTTP_1_0 = "HTTP/1.0"
|
|
11
12
|
HTTP_1_1 = "HTTP/1.1"
|
|
12
13
|
_HTTP_METHOD_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
13
14
|
|
|
@@ -256,7 +257,15 @@ def send_http_response(conn, response, *, send_body=True, keep_alive=False, vers
|
|
|
256
257
|
headers.pop(name)
|
|
257
258
|
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
258
259
|
elif getattr(response, "is_stream", False):
|
|
259
|
-
if
|
|
260
|
+
if version == HTTP_1_0:
|
|
261
|
+
# RFC 9112 section 6.1: an HTTP/1.0 client does not understand
|
|
262
|
+
# Transfer-Encoding, so without a length the close ends the body.
|
|
263
|
+
for name in list(headers):
|
|
264
|
+
if name.lower() == "transfer-encoding":
|
|
265
|
+
headers.pop(name)
|
|
266
|
+
if "content-length" not in lowermap:
|
|
267
|
+
keep_alive = False
|
|
268
|
+
elif "transfer-encoding" not in lowermap and "content-length" not in lowermap:
|
|
260
269
|
headers["Transfer-Encoding"] = "chunked"
|
|
261
270
|
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
262
271
|
elif "content-length" not in lowermap:
|
|
@@ -93,6 +93,11 @@ class BufferedReader:
|
|
|
93
93
|
return False
|
|
94
94
|
return bytes(self._buffer[: len(prefix)]) == prefix
|
|
95
95
|
|
|
96
|
+
def wait_for_data(self):
|
|
97
|
+
"""Block until at least one byte is buffered, reading only if empty."""
|
|
98
|
+
if not self._buffer:
|
|
99
|
+
self._fill()
|
|
100
|
+
|
|
96
101
|
def unread(self, data):
|
|
97
102
|
"""Push bytes back so the next read sees them first."""
|
|
98
103
|
if data:
|
|
@@ -228,7 +233,7 @@ def client_wants_keep_alive(version, headers):
|
|
|
228
233
|
return False
|
|
229
234
|
|
|
230
235
|
|
|
231
|
-
def response_is_self_delimiting(response, *, send_body=True):
|
|
236
|
+
def response_is_self_delimiting(response, *, send_body=True, version=None):
|
|
232
237
|
"""True when the client can tell where the response ends without EOF."""
|
|
233
238
|
status = int(getattr(response, "status", 200))
|
|
234
239
|
if 100 <= status < 200 or status in (204, 304) or not send_body:
|
|
@@ -240,6 +245,9 @@ def response_is_self_delimiting(response, *, send_body=True):
|
|
|
240
245
|
if not getattr(response, "is_stream", False):
|
|
241
246
|
# A buffered body always gets a Content-Length when serialized.
|
|
242
247
|
return True
|
|
248
|
+
if version == HTTP_1_0:
|
|
249
|
+
# Chunked coding is never sent to HTTP/1.0, so only EOF delimits.
|
|
250
|
+
return False
|
|
243
251
|
for name, value in headers.items():
|
|
244
252
|
if str(name).lower() == "transfer-encoding":
|
|
245
253
|
return "chunked" in str(value).lower()
|
|
@@ -256,7 +264,7 @@ def should_keep_alive(version, request_headers, response, *, send_body=True, ser
|
|
|
256
264
|
return False
|
|
257
265
|
if "close" in parse_connection_tokens(getattr(response, "headers", None)):
|
|
258
266
|
return False
|
|
259
|
-
return response_is_self_delimiting(response, send_body=send_body)
|
|
267
|
+
return response_is_self_delimiting(response, send_body=send_body, version=version)
|
|
260
268
|
|
|
261
269
|
|
|
262
270
|
def connection_header_value(version, keep_alive):
|
|
@@ -12,8 +12,9 @@ it would desynchronise the decoder for every later stream.
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import struct
|
|
15
|
+
import threading
|
|
15
16
|
|
|
16
|
-
from .hpack import Decoder, Encoder, HPACKError
|
|
17
|
+
from .hpack import Decoder, Encoder, HeaderListTooLarge, HPACKError
|
|
17
18
|
from .http import Response
|
|
18
19
|
from .logs import get_log
|
|
19
20
|
|
|
@@ -319,18 +320,41 @@ class Http2Connection:
|
|
|
319
320
|
|
|
320
321
|
Takes anything with ``recv`` and ``sendall``, so the frame logic is
|
|
321
322
|
exercised in tests without a socket.
|
|
323
|
+
|
|
324
|
+
Streamed response bodies are produced on their own thread so the frame
|
|
325
|
+
loop keeps reading (WINDOW_UPDATE, other streams) meanwhile; a lock
|
|
326
|
+
serialises frame handling and sending between the two.
|
|
322
327
|
"""
|
|
323
328
|
|
|
329
|
+
#: Ids of streams closed by RST_STREAM remembered so that a HEADERS frame
|
|
330
|
+
#: still in flight on one is decoded and ignored, not a protocol error.
|
|
331
|
+
RECENTLY_RESET_STREAMS = 256
|
|
332
|
+
#: Body bytes a streaming producer may queue ahead of the peer's windows.
|
|
333
|
+
MAX_PENDING_STREAM_BYTES = 64 * 1024
|
|
334
|
+
#: How long a closing connection waits for producers to finish their bodies.
|
|
335
|
+
PRODUCER_GRACE_SECONDS = 5.0
|
|
336
|
+
|
|
324
337
|
def __init__(
|
|
325
|
-
self,
|
|
338
|
+
self,
|
|
339
|
+
conn,
|
|
340
|
+
app,
|
|
341
|
+
*,
|
|
342
|
+
tls=None,
|
|
343
|
+
client_address=("", 0),
|
|
344
|
+
max_streams=100,
|
|
345
|
+
max_body_bytes=None,
|
|
346
|
+
max_header_bytes=None,
|
|
326
347
|
):
|
|
327
348
|
self.conn = conn
|
|
328
349
|
#: Largest request body buffered per stream; None leaves it unbounded.
|
|
329
350
|
self.max_body_bytes = None if max_body_bytes is None else int(max_body_bytes)
|
|
351
|
+
#: Ceiling for a header block and its decoded list; None is unbounded.
|
|
352
|
+
self.max_header_bytes = None if max_header_bytes is None else int(max_header_bytes)
|
|
330
353
|
self.app = app
|
|
331
354
|
self.tls = tls or {}
|
|
332
355
|
self.client_address = client_address
|
|
333
356
|
self.streams = {}
|
|
357
|
+
self._recently_reset = {}
|
|
334
358
|
self.decoder = Decoder()
|
|
335
359
|
self.encoder = Encoder()
|
|
336
360
|
self.inbound = FlowControlWindow()
|
|
@@ -341,6 +365,8 @@ class Http2Connection:
|
|
|
341
365
|
SETTINGS_INITIAL_WINDOW_SIZE: DEFAULT_INITIAL_WINDOW_SIZE,
|
|
342
366
|
SETTINGS_MAX_FRAME_SIZE: DEFAULT_MAX_FRAME_SIZE,
|
|
343
367
|
}
|
|
368
|
+
if self.max_header_bytes is not None:
|
|
369
|
+
self.local_settings[SETTINGS_MAX_HEADER_LIST_SIZE] = self.max_header_bytes
|
|
344
370
|
self.peer_settings = {
|
|
345
371
|
SETTINGS_INITIAL_WINDOW_SIZE: DEFAULT_INITIAL_WINDOW_SIZE,
|
|
346
372
|
SETTINGS_MAX_FRAME_SIZE: DEFAULT_MAX_FRAME_SIZE,
|
|
@@ -350,6 +376,13 @@ class Http2Connection:
|
|
|
350
376
|
self._buffer = bytearray()
|
|
351
377
|
self._pending_headers = None
|
|
352
378
|
self._closed = False
|
|
379
|
+
self._lock = threading.RLock()
|
|
380
|
+
self._output_ready = threading.Condition(self._lock)
|
|
381
|
+
self._producers = []
|
|
382
|
+
#: False once the frame loop stops: no more window credit can arrive.
|
|
383
|
+
self._reading = True
|
|
384
|
+
#: Set when producers must stop at once (connection error or timeout).
|
|
385
|
+
self._abandoned = False
|
|
353
386
|
|
|
354
387
|
# -- transport -----------------------------------------------------
|
|
355
388
|
|
|
@@ -408,19 +441,54 @@ class Http2Connection:
|
|
|
408
441
|
"""Run the connection until the peer goes away or a fault ends it."""
|
|
409
442
|
try:
|
|
410
443
|
self.read_preface()
|
|
411
|
-
self.
|
|
444
|
+
with self._lock:
|
|
445
|
+
self.send_settings()
|
|
412
446
|
while not self._closed:
|
|
413
|
-
|
|
414
|
-
|
|
447
|
+
try:
|
|
448
|
+
frame = self.next_frame()
|
|
449
|
+
except TimeoutError:
|
|
450
|
+
# A client reading a long streamed body may send nothing;
|
|
451
|
+
# _read keeps any partial frame, so waiting again is safe.
|
|
452
|
+
if self._streaming():
|
|
453
|
+
continue
|
|
454
|
+
raise
|
|
455
|
+
with self._lock:
|
|
456
|
+
self.handle_frame(frame)
|
|
457
|
+
if self._producers:
|
|
458
|
+
self._output_ready.notify_all()
|
|
415
459
|
except ConnectionError_ as exc:
|
|
416
|
-
self.
|
|
460
|
+
self._abandoned = True
|
|
461
|
+
with self._lock:
|
|
462
|
+
self.send_goaway(exc.code, str(exc).encode("utf-8", "replace")[:128])
|
|
417
463
|
except (ConnectionError, OSError):
|
|
418
464
|
pass
|
|
419
465
|
except HPACKError as exc:
|
|
466
|
+
self._abandoned = True
|
|
420
467
|
# A broken header block leaves the shared table unusable.
|
|
421
|
-
self.
|
|
468
|
+
with self._lock:
|
|
469
|
+
self.send_goaway(
|
|
470
|
+
ERROR_COMPRESSION_ERROR, str(exc).encode("utf-8", "replace")[:128]
|
|
471
|
+
)
|
|
422
472
|
else:
|
|
423
|
-
self.
|
|
473
|
+
with self._lock:
|
|
474
|
+
self.send_goaway(ERROR_NO_ERROR)
|
|
475
|
+
finally:
|
|
476
|
+
self._finish_producers()
|
|
477
|
+
|
|
478
|
+
def _streaming(self):
|
|
479
|
+
self._producers = [thread for thread in self._producers if thread.is_alive()]
|
|
480
|
+
return bool(self._producers)
|
|
481
|
+
|
|
482
|
+
def _finish_producers(self):
|
|
483
|
+
"""Let streamed bodies finish, then stop any that cannot."""
|
|
484
|
+
with self._lock:
|
|
485
|
+
self._reading = False
|
|
486
|
+
self._output_ready.notify_all()
|
|
487
|
+
for thread in list(self._producers):
|
|
488
|
+
thread.join(self.PRODUCER_GRACE_SECONDS)
|
|
489
|
+
self._abandoned = True
|
|
490
|
+
with self._lock:
|
|
491
|
+
self._output_ready.notify_all()
|
|
424
492
|
|
|
425
493
|
# -- frame handling ------------------------------------------------
|
|
426
494
|
|
|
@@ -451,6 +519,22 @@ class Http2Connection:
|
|
|
451
519
|
stream.state = CLOSED
|
|
452
520
|
stream.reset_code = exc.code
|
|
453
521
|
self.send_rst_stream(exc.stream_id, exc.code)
|
|
522
|
+
self._release_closed_streams()
|
|
523
|
+
|
|
524
|
+
def _release_closed_streams(self):
|
|
525
|
+
"""Forget closed streams so their bodies and headers can be freed.
|
|
526
|
+
|
|
527
|
+
Nothing more is sent on a closed stream, and frames that may still
|
|
528
|
+
arrive for one are handled without its state.
|
|
529
|
+
"""
|
|
530
|
+
for stream_id, stream in list(self.streams.items()):
|
|
531
|
+
if not stream.closed:
|
|
532
|
+
continue
|
|
533
|
+
del self.streams[stream_id]
|
|
534
|
+
if stream.reset_code is not None:
|
|
535
|
+
self._recently_reset[stream_id] = None
|
|
536
|
+
while len(self._recently_reset) > self.RECENTLY_RESET_STREAMS:
|
|
537
|
+
del self._recently_reset[next(iter(self._recently_reset))]
|
|
454
538
|
|
|
455
539
|
def _require_stream_id(self, frame, expected_zero):
|
|
456
540
|
if expected_zero and frame.stream_id != 0:
|
|
@@ -534,8 +618,14 @@ class Http2Connection:
|
|
|
534
618
|
self._require_stream_id(frame, False)
|
|
535
619
|
if frame.stream_id % 2 == 0:
|
|
536
620
|
raise ConnectionError_("clients open odd-numbered streams only")
|
|
537
|
-
|
|
538
|
-
|
|
621
|
+
stream = self.streams.get(frame.stream_id)
|
|
622
|
+
if stream is None and frame.stream_id <= self.last_stream_id:
|
|
623
|
+
if frame.stream_id not in self._recently_reset:
|
|
624
|
+
raise ConnectionError_("stream identifiers must increase")
|
|
625
|
+
# Section 5.4.2: frames on a stream we reset are ignored, but the
|
|
626
|
+
# block is still decoded to keep the shared HPACK table in step.
|
|
627
|
+
stream = Stream(frame.stream_id)
|
|
628
|
+
stream.state = CLOSED
|
|
539
629
|
|
|
540
630
|
payload = strip_padding(frame.payload, frame.flags, frame.stream_id)
|
|
541
631
|
if frame.has(FLAG_PRIORITY):
|
|
@@ -543,7 +633,6 @@ class Http2Connection:
|
|
|
543
633
|
raise ConnectionError_("HEADERS priority block truncated", ERROR_FRAME_SIZE_ERROR)
|
|
544
634
|
payload = payload[5:]
|
|
545
635
|
|
|
546
|
-
stream = self.streams.get(frame.stream_id)
|
|
547
636
|
if stream is None:
|
|
548
637
|
limit = self.local_settings[SETTINGS_MAX_CONCURRENT_STREAMS]
|
|
549
638
|
live = sum(1 for s in self.streams.values() if not s.closed)
|
|
@@ -555,6 +644,7 @@ class Http2Connection:
|
|
|
555
644
|
self.last_stream_id = max(self.last_stream_id, frame.stream_id)
|
|
556
645
|
|
|
557
646
|
self._pending_headers = (stream, bytearray(payload), frame.has(FLAG_END_STREAM))
|
|
647
|
+
self._check_header_block_size(self._pending_headers[1])
|
|
558
648
|
if frame.has(FLAG_END_HEADERS):
|
|
559
649
|
self._finish_header_block()
|
|
560
650
|
|
|
@@ -565,15 +655,35 @@ class Http2Connection:
|
|
|
565
655
|
if frame.stream_id != stream.id:
|
|
566
656
|
raise ConnectionError_("CONTINUATION on a different stream")
|
|
567
657
|
buffer.extend(frame.payload)
|
|
658
|
+
self._check_header_block_size(buffer)
|
|
568
659
|
if frame.has(FLAG_END_HEADERS):
|
|
569
660
|
self._finish_header_block()
|
|
570
661
|
|
|
662
|
+
def _check_header_block_size(self, buffer):
|
|
663
|
+
# A block cut short cannot be decoded, and the HPACK table is shared,
|
|
664
|
+
# so an oversized block can only end the connection.
|
|
665
|
+
if self.max_header_bytes is not None and len(buffer) > self.max_header_bytes:
|
|
666
|
+
raise ConnectionError_(
|
|
667
|
+
f"header block over the {self.max_header_bytes} octet limit",
|
|
668
|
+
ERROR_ENHANCE_YOUR_CALM,
|
|
669
|
+
)
|
|
670
|
+
|
|
571
671
|
def _finish_header_block(self):
|
|
572
672
|
stream, buffer, end_stream = self._pending_headers
|
|
573
673
|
self._pending_headers = None
|
|
574
674
|
# Decoded even when the stream is doomed: the HPACK table is shared,
|
|
575
675
|
# so skipping a block would desync every stream that follows.
|
|
576
|
-
|
|
676
|
+
try:
|
|
677
|
+
headers = self.decoder.decode(bytes(buffer), max_list_size=self.max_header_bytes)
|
|
678
|
+
except HeaderListTooLarge:
|
|
679
|
+
if stream.closed:
|
|
680
|
+
return
|
|
681
|
+
if not stream.headers:
|
|
682
|
+
stream.open_remote(end_stream)
|
|
683
|
+
self._refuse_early(
|
|
684
|
+
stream, Response.text("Request Header Fields Too Large", status=431)
|
|
685
|
+
)
|
|
686
|
+
return
|
|
577
687
|
if stream.closed:
|
|
578
688
|
return
|
|
579
689
|
if stream.headers:
|
|
@@ -637,14 +747,20 @@ class Http2Connection:
|
|
|
637
747
|
return False
|
|
638
748
|
|
|
639
749
|
def _reject_body(self, stream):
|
|
640
|
-
"""Answer 413 before the body is complete, then stop the upload.
|
|
750
|
+
"""Answer 413 before the body is complete, then stop the upload."""
|
|
751
|
+
self._refuse_early(stream, Response.text("Payload Too Large", status=413))
|
|
752
|
+
|
|
753
|
+
def _refuse_early(self, stream, response):
|
|
754
|
+
"""Answer ``response`` without dispatching, then stop the upload.
|
|
641
755
|
|
|
642
756
|
RFC 9113 section 8.1 lets a server respond early and reset the
|
|
643
757
|
request stream with NO_ERROR so the client stops sending.
|
|
644
758
|
"""
|
|
645
759
|
stream.body_rejected = True
|
|
646
760
|
stream.body = bytearray()
|
|
647
|
-
self.send_response(stream,
|
|
761
|
+
self.send_response(stream, response)
|
|
762
|
+
if stream.closed:
|
|
763
|
+
return # The request had already ended; nothing left to stop.
|
|
648
764
|
if not stream.pending_output:
|
|
649
765
|
stream.state = CLOSED
|
|
650
766
|
stream.reset_code = ERROR_NO_ERROR
|
|
@@ -688,8 +804,22 @@ class Http2Connection:
|
|
|
688
804
|
continue # Section 8.2.2: these have no meaning in HTTP/2.
|
|
689
805
|
fields.append((lowered, str(value)))
|
|
690
806
|
|
|
691
|
-
|
|
692
|
-
|
|
807
|
+
if send_body and response.is_stream:
|
|
808
|
+
# Sent as it is produced: joining it first would hold an endless
|
|
809
|
+
# stream forever and a large one entirely in memory.
|
|
810
|
+
self._send_header_block(stream.id, self.encoder.encode(fields), False)
|
|
811
|
+
producer = threading.Thread(
|
|
812
|
+
target=self._produce,
|
|
813
|
+
args=(stream, response),
|
|
814
|
+
name=f"http2-stream-{stream.id}",
|
|
815
|
+
daemon=True,
|
|
816
|
+
)
|
|
817
|
+
self._producers.append(producer)
|
|
818
|
+
producer.start()
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
body = bytes(response.body) if send_body else b""
|
|
822
|
+
if send_body:
|
|
693
823
|
fields.append(("content-length", str(len(body))))
|
|
694
824
|
|
|
695
825
|
block = self.encoder.encode(fields)
|
|
@@ -699,13 +829,51 @@ class Http2Connection:
|
|
|
699
829
|
else:
|
|
700
830
|
stream.end_local()
|
|
701
831
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
832
|
+
def _stream_abandoned(self, stream):
|
|
833
|
+
return self._abandoned or stream.closed
|
|
834
|
+
|
|
835
|
+
def _produce(self, stream, response):
|
|
836
|
+
"""Send a streamed body chunk by chunk, within the peer's windows."""
|
|
706
837
|
from .http import _iter_stream_chunks
|
|
707
838
|
|
|
708
|
-
|
|
839
|
+
try:
|
|
840
|
+
for chunk in _iter_stream_chunks(response.stream):
|
|
841
|
+
with self._lock:
|
|
842
|
+
if self._stream_abandoned(stream):
|
|
843
|
+
return
|
|
844
|
+
stream.pending_output.extend(chunk)
|
|
845
|
+
self._flush_stream(stream)
|
|
846
|
+
while len(stream.pending_output) >= self.MAX_PENDING_STREAM_BYTES:
|
|
847
|
+
if self._stream_abandoned(stream) or not self._reading:
|
|
848
|
+
return # No window credit can arrive any more.
|
|
849
|
+
self._output_ready.wait(0.5)
|
|
850
|
+
with self._lock:
|
|
851
|
+
if self._stream_abandoned(stream):
|
|
852
|
+
return
|
|
853
|
+
if stream.pending_output:
|
|
854
|
+
stream.pending_end_stream = True
|
|
855
|
+
self._flush_stream(stream)
|
|
856
|
+
else:
|
|
857
|
+
self._send(Frame(FRAME_DATA, FLAG_END_STREAM, stream.id, b""))
|
|
858
|
+
stream.end_local()
|
|
859
|
+
except OSError:
|
|
860
|
+
pass # The peer is gone; nothing more can be sent.
|
|
861
|
+
except Exception as exc: # noqa: BLE001 - reported on the stream
|
|
862
|
+
get_log(self.app).exception("http2", f"stream {stream.id} body failed: {exc}", exc)
|
|
863
|
+
with self._lock:
|
|
864
|
+
if not stream.closed:
|
|
865
|
+
stream.state = CLOSED
|
|
866
|
+
stream.reset_code = ERROR_INTERNAL_ERROR
|
|
867
|
+
self.send_rst_stream(stream.id, ERROR_INTERNAL_ERROR)
|
|
868
|
+
finally:
|
|
869
|
+
close = getattr(response.stream, "close", None)
|
|
870
|
+
if callable(close):
|
|
871
|
+
try:
|
|
872
|
+
close()
|
|
873
|
+
except Exception as exc: # noqa: BLE001 - cleanup only
|
|
874
|
+
get_log(self.app).warning("http2", f"stream {stream.id} cleanup failed: {exc}")
|
|
875
|
+
with self._lock:
|
|
876
|
+
self._release_closed_streams()
|
|
709
877
|
|
|
710
878
|
def _send_header_block(self, stream_id, block, end_stream):
|
|
711
879
|
limit = self.peer_settings[SETTINGS_MAX_FRAME_SIZE]
|