wsbuilder 1.2.7__tar.gz → 1.2.8__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.
Files changed (132) hide show
  1. {wsbuilder-1.2.7/src/wsbuilder.egg-info → wsbuilder-1.2.8}/PKG-INFO +1 -1
  2. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/data-orm.md +4 -0
  3. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/pyproject.toml +1 -1
  4. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/__init__.py +1 -1
  5. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/app.py +5 -0
  6. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/cache.py +4 -2
  7. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/hpack.py +45 -11
  8. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http.py +10 -1
  9. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http1.py +10 -2
  10. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http2.py +189 -21
  11. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http3_server.py +63 -9
  12. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/orm.py +37 -16
  13. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/qpack.py +11 -6
  14. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/security.py +30 -10
  15. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/server.py +24 -5
  16. {wsbuilder-1.2.7 → wsbuilder-1.2.8/src/wsbuilder.egg-info}/PKG-INFO +1 -1
  17. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_app_threads.py +28 -0
  18. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_cache.py +7 -0
  19. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_hpack.py +33 -0
  20. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http1.py +20 -0
  21. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http2.py +198 -0
  22. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http3.py +7 -0
  23. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http3_server.py +57 -0
  24. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http3_streaming_udp.py +26 -0
  25. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_orm.py +39 -0
  26. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_security.py +34 -0
  27. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_server.py +93 -0
  28. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/LICENSE +0 -0
  29. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/MANIFEST.in +0 -0
  30. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/README.md +0 -0
  31. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/404.html +0 -0
  32. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/CNAME +0 -0
  33. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/advanced.md +0 -0
  34. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/ai-predict.md +0 -0
  35. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/api-map.md +0 -0
  36. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/app-http.md +0 -0
  37. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/architecture.md +0 -0
  38. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/assets/wsbuilder-mark.svg +0 -0
  39. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/beginners.md +0 -0
  40. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/cache-security.md +0 -0
  41. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/cache.md +0 -0
  42. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/capabilities.md +0 -0
  43. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/dns.md +0 -0
  44. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/full-project.md +0 -0
  45. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/getting-started.md +0 -0
  46. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/help/index.md +0 -0
  47. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/http-versions.md +0 -0
  48. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/http.md +0 -0
  49. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/ia.md +0 -0
  50. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/index.md +0 -0
  51. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/install.md +0 -0
  52. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/intermediate.md +0 -0
  53. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/mdns.md +0 -0
  54. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/network-edge.md +0 -0
  55. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/observability-tasks.md +0 -0
  56. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/observability.md +0 -0
  57. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/persistence.md +0 -0
  58. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/proxy.md +0 -0
  59. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/reference/index.md +0 -0
  60. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/security.md +0 -0
  61. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/stylesheets/extra.css +0 -0
  62. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/tasks.md +0 -0
  63. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/tls-certificates.md +0 -0
  64. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/docs/websocket.md +0 -0
  65. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/examples/full-project/.gitignore +0 -0
  66. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/examples/full-project/README.md +0 -0
  67. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/examples/full-project/app.py +0 -0
  68. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/examples/full-project/pyproject.toml +0 -0
  69. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/examples/full-project/upstream.py +0 -0
  70. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/mkdocs.yml +0 -0
  71. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/setup.cfg +0 -0
  72. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/__main__.py +0 -0
  73. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/_coerce.py +0 -0
  74. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/caches.py +0 -0
  75. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/constants.py +0 -0
  76. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/cookies.py +0 -0
  77. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/db_replicas.py +0 -0
  78. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/dns.py +0 -0
  79. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/framework.py +0 -0
  80. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/headers.py +0 -0
  81. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http3.py +0 -0
  82. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/http3_websocket.py +0 -0
  83. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/ia.py +0 -0
  84. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/logs.py +0 -0
  85. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/mdns.py +0 -0
  86. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/metrics.py +0 -0
  87. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/pki.py +0 -0
  88. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/predicts.py +0 -0
  89. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/proxyi.py +0 -0
  90. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/__init__.py +0 -0
  91. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/address.py +0 -0
  92. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/connection.py +0 -0
  93. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/crypto.py +0 -0
  94. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/frames.py +0 -0
  95. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/packet.py +0 -0
  96. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/recovery.py +0 -0
  97. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/tls.py +0 -0
  98. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/quic/varint.py +0 -0
  99. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/tasks.py +0 -0
  100. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder/ws.py +0 -0
  101. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder.egg-info/SOURCES.txt +0 -0
  102. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
  103. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder.egg-info/entry_points.txt +0 -0
  104. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder.egg-info/requires.txt +0 -0
  105. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/src/wsbuilder.egg-info/top_level.txt +0 -0
  106. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_caches.py +0 -0
  107. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_contributing_policy.py +0 -0
  108. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_demo_monitor.py +0 -0
  109. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_dns.py +0 -0
  110. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_docs.py +0 -0
  111. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_documentation_files.py +0 -0
  112. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_headers_cookies.py +0 -0
  113. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http.py +0 -0
  114. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_http3_streaming.py +0 -0
  115. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_ia.py +0 -0
  116. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_logs.py +0 -0
  117. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_mdns.py +0 -0
  118. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_metrics.py +0 -0
  119. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_orm_replicas.py +0 -0
  120. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_pki.py +0 -0
  121. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_pr_identity_policy.py +0 -0
  122. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_predicts.py +0 -0
  123. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_proxyi.py +0 -0
  124. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_proxyi_routing.py +0 -0
  125. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_quic_address.py +0 -0
  126. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_quic_crypto.py +0 -0
  127. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_quic_protocol.py +0 -0
  128. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_quic_recovery.py +0 -0
  129. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_quic_tls.py +0 -0
  130. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_server_errors.py +0 -0
  131. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_tasks.py +0 -0
  132. {wsbuilder-1.2.7 → wsbuilder-1.2.8}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsbuilder
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Summary: Lightweight HTTP + WebSocket framework for Python.
5
5
  Author: wsbuilder Authors
6
6
  License-Expression: MIT
@@ -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`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsbuilder"
7
- version = "1.2.7"
7
+ version = "1.2.8"
8
8
  description = "Lightweight HTTP + WebSocket framework for Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -126,7 +126,7 @@ def __getattr__(name):
126
126
  def __dir__():
127
127
  return sorted(set(globals()) | _PKI_EXPORTS)
128
128
 
129
- __version__ = "1.2.7"
129
+ __version__ = "1.2.8"
130
130
 
131
131
  __all__ = [
132
132
  "App",
@@ -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
- sql += " AND key LIKE ?"
838
- params.append(f"{str(prefix)}%")
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
- return raw.decode("utf-8", errors="strict"), end
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("utf-8")) + len(value.encode("utf-8")) + ENTRY_OVERHEAD
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
- headers.append(self._lookup(index))
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
- headers.append((name, value))
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
- headers.append((name, value))
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 "transfer-encoding" not in lowermap and "content-length" not in lowermap:
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, conn, app, *, tls=None, client_address=("", 0), max_streams=100, max_body_bytes=None
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.send_settings()
444
+ with self._lock:
445
+ self.send_settings()
412
446
  while not self._closed:
413
- frame = self.next_frame()
414
- self.handle_frame(frame)
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.send_goaway(exc.code, str(exc).encode("utf-8", "replace")[:128])
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.send_goaway(ERROR_COMPRESSION_ERROR, str(exc).encode("utf-8", "replace")[:128])
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.send_goaway(ERROR_NO_ERROR)
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
- if frame.stream_id <= self.last_stream_id and frame.stream_id not in self.streams:
538
- raise ConnectionError_("stream identifiers must increase")
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
- headers = self.decoder.decode(bytes(buffer))
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, Response.text("Payload Too Large", status=413))
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
- body = b"" if not send_body else self._response_body(response)
692
- if send_body and not response.is_stream:
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
- @staticmethod
703
- def _response_body(response):
704
- if not response.is_stream:
705
- return bytes(response.body)
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
- return b"".join(_iter_stream_chunks(response.stream))
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]