wsbuilder 1.0.3__tar.gz → 1.2.0__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.0.3/src/wsbuilder.egg-info → wsbuilder-1.2.0}/PKG-INFO +1 -1
  2. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/advanced.md +9 -5
  3. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/app-http.md +53 -4
  4. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/observability.md +24 -0
  5. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/websocket.md +14 -0
  6. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/pyproject.toml +1 -1
  7. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/__init__.py +1 -1
  8. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/__main__.py +27 -1
  9. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/app.py +22 -4
  10. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/db_replicas.py +3 -1
  11. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/framework.py +2 -0
  12. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http.py +3 -2
  13. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http2.py +46 -2
  14. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3.py +39 -3
  15. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3_server.py +34 -12
  16. wsbuilder-1.2.0/src/wsbuilder/logs.py +208 -0
  17. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/orm.py +3 -1
  18. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/connection.py +104 -17
  19. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/server.py +123 -15
  20. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ws.py +44 -9
  21. {wsbuilder-1.0.3 → wsbuilder-1.2.0/src/wsbuilder.egg-info}/PKG-INFO +1 -1
  22. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http2.py +54 -2
  23. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3.py +29 -0
  24. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_server.py +120 -0
  25. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_streaming.py +44 -1
  26. wsbuilder-1.2.0/tests/test_logs.py +226 -0
  27. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_server.py +208 -0
  28. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_ws.py +47 -0
  29. wsbuilder-1.0.3/src/wsbuilder/logs.py +0 -67
  30. wsbuilder-1.0.3/tests/test_logs.py +0 -101
  31. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/LICENSE +0 -0
  32. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/MANIFEST.in +0 -0
  33. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/README.md +0 -0
  34. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/404.html +0 -0
  35. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/CNAME +0 -0
  36. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/ai-predict.md +0 -0
  37. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/api-map.md +0 -0
  38. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/architecture.md +0 -0
  39. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/assets/wsbuilder-mark.svg +0 -0
  40. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/beginners.md +0 -0
  41. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/cache-security.md +0 -0
  42. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/cache.md +0 -0
  43. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/capabilities.md +0 -0
  44. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/data-orm.md +0 -0
  45. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/dns.md +0 -0
  46. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/full-project.md +0 -0
  47. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/getting-started.md +0 -0
  48. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/help/index.md +0 -0
  49. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/http-versions.md +0 -0
  50. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/http.md +0 -0
  51. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/ia.md +0 -0
  52. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/index.md +0 -0
  53. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/install.md +0 -0
  54. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/intermediate.md +0 -0
  55. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/mdns.md +0 -0
  56. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/network-edge.md +0 -0
  57. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/observability-tasks.md +0 -0
  58. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/persistence.md +0 -0
  59. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/proxy.md +0 -0
  60. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/reference/index.md +0 -0
  61. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/security.md +0 -0
  62. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/stylesheets/extra.css +0 -0
  63. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/tasks.md +0 -0
  64. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/tls-certificates.md +0 -0
  65. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/.gitignore +0 -0
  66. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/README.md +0 -0
  67. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/app.py +0 -0
  68. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/pyproject.toml +0 -0
  69. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/upstream.py +0 -0
  70. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/mkdocs.yml +0 -0
  71. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/setup.cfg +0 -0
  72. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/cache.py +0 -0
  73. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/caches.py +0 -0
  74. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/constants.py +0 -0
  75. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/cookies.py +0 -0
  76. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/dns.py +0 -0
  77. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/headers.py +0 -0
  78. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/hpack.py +0 -0
  79. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http1.py +0 -0
  80. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3_websocket.py +0 -0
  81. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ia.py +0 -0
  82. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/mdns.py +0 -0
  83. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/metrics.py +0 -0
  84. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/pki.py +0 -0
  85. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/predicts.py +0 -0
  86. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/proxyi.py +0 -0
  87. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/qpack.py +0 -0
  88. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/__init__.py +0 -0
  89. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/address.py +0 -0
  90. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/crypto.py +0 -0
  91. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/frames.py +0 -0
  92. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/packet.py +0 -0
  93. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/recovery.py +0 -0
  94. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/tls.py +0 -0
  95. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/varint.py +0 -0
  96. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/security.py +0 -0
  97. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/tasks.py +0 -0
  98. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ws_demo.py +0 -0
  99. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/SOURCES.txt +0 -0
  100. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
  101. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/entry_points.txt +0 -0
  102. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/requires.txt +0 -0
  103. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/top_level.txt +0 -0
  104. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_app_threads.py +0 -0
  105. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_cache.py +0 -0
  106. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_caches.py +0 -0
  107. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_contributing_policy.py +0 -0
  108. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_demo_monitor.py +0 -0
  109. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_dns.py +0 -0
  110. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_docs.py +0 -0
  111. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_documentation_files.py +0 -0
  112. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_headers_cookies.py +0 -0
  113. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_hpack.py +0 -0
  114. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http.py +0 -0
  115. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http1.py +0 -0
  116. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_streaming_udp.py +0 -0
  117. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_ia.py +0 -0
  118. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_mdns.py +0 -0
  119. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_metrics.py +0 -0
  120. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_orm.py +0 -0
  121. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_orm_replicas.py +0 -0
  122. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_pki.py +0 -0
  123. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_pr_identity_policy.py +0 -0
  124. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_predicts.py +0 -0
  125. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_proxyi.py +0 -0
  126. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_address.py +0 -0
  127. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_crypto.py +0 -0
  128. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_protocol.py +0 -0
  129. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_recovery.py +0 -0
  130. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_tls.py +0 -0
  131. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_security.py +0 -0
  132. {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsbuilder
3
- Version: 1.0.3
3
+ Version: 1.2.0
4
4
  Summary: Lightweight HTTP + WebSocket framework for Python.
5
5
  Author: wsbuilder Authors
6
6
  License-Expression: MIT
@@ -13,11 +13,15 @@ limites de transporte, instancia el servidor:
13
13
  from wsbuilder import App, HTTPServer
14
14
 
15
15
  app = App()
16
- server = HTTPServer("127.0.0.1", 8765, app)
17
- server.MAX_CONNECTION_WORKERS = 128
18
- server.MAX_REQUEST_HEADER_BYTES = 128 * 1024
19
- server.MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024
20
- server.REQUEST_READ_TIMEOUT_SECONDS = 15.0
16
+ server = HTTPServer(
17
+ "127.0.0.1",
18
+ 8765,
19
+ app,
20
+ max_connection_workers=128,
21
+ max_request_header_bytes=128 * 1024,
22
+ max_request_body_bytes=4 * 1024 * 1024,
23
+ request_read_timeout=15.0,
24
+ )
21
25
  server.serve_forever()
22
26
  ```
23
27
 
@@ -250,19 +250,68 @@ Limites por defecto:
250
250
  | `MAX_REQUEST_HEADER_BYTES` | `64 KiB` |
251
251
  | `MAX_REQUEST_BODY_BYTES` | `2 MiB` |
252
252
  | `REQUEST_READ_TIMEOUT_SECONDS` | `10.0` segundos |
253
+ | `KEEPALIVE_TIMEOUT_SECONDS` | `5.0` segundos entre peticiones |
254
+ | `MAX_KEEPALIVE_REQUESTS` | `100` peticiones por conexion |
255
+ | `MAX_CONCURRENT_STREAMS` | `100` streams HTTP/2 |
253
256
  | `ACCEPT_TIMEOUT_SECONDS` | `0.5` segundos |
257
+ | `GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS` | `10.0` segundos de espera al apagar |
258
+ | `HANDLE_SIGTERM` | `True`: `SIGTERM` detiene el servidor de forma ordenada |
259
+ | `MAX_WS_FRAME_BYTES` / `MAX_WS_MESSAGE_BYTES` | `None`: usa los `2 MiB` de `wsbuilder.ws` |
254
260
 
255
- Para cambiarlos, instancia `HTTPServer` manualmente y ajusta atributos antes de
256
- llamar `serve_forever()`.
261
+ Se cambian con argumentos al crear el servidor, en `app.run()` o desde la CLI.
262
+ Un valor invalido (cero workers, timeout `0`, tamano negativo) lanza
263
+ `ValueError`; una clave desconocida, `TypeError`.
257
264
 
258
265
  ```python
259
266
  from wsbuilder import HTTPServer
260
267
 
261
- server = HTTPServer("127.0.0.1", 8765, app)
262
- server.MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024
268
+ server = HTTPServer(
269
+ "127.0.0.1",
270
+ 8765,
271
+ app,
272
+ max_connection_workers=128,
273
+ max_request_body_bytes=4 * 1024 * 1024,
274
+ keepalive_timeout=2.0,
275
+ )
263
276
  server.serve_forever()
277
+
278
+ # equivalente
279
+ app.run("127.0.0.1", 8765, max_request_body_bytes=4 * 1024 * 1024)
280
+ ```
281
+
282
+ | Argumento | Atributo | CLI |
283
+ | --- | --- | --- |
284
+ | `max_connection_workers` | `MAX_CONNECTION_WORKERS` | `--max-workers` |
285
+ | `max_request_header_bytes` | `MAX_REQUEST_HEADER_BYTES` | `--max-header-bytes` |
286
+ | `max_request_body_bytes` | `MAX_REQUEST_BODY_BYTES` | `--max-body-bytes` |
287
+ | `request_read_timeout` | `REQUEST_READ_TIMEOUT_SECONDS` | `--request-timeout` |
288
+ | `keepalive_timeout` | `KEEPALIVE_TIMEOUT_SECONDS` | `--keepalive-timeout` |
289
+ | `max_keepalive_requests` | `MAX_KEEPALIVE_REQUESTS` | `--max-keepalive-requests` |
290
+ | `max_concurrent_streams` | `MAX_CONCURRENT_STREAMS` | |
291
+ | `graceful_shutdown_timeout` | `GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS` | `--shutdown-timeout` |
292
+ | `max_ws_frame_bytes` | `MAX_WS_FRAME_BYTES` | |
293
+ | `max_ws_message_bytes` | `MAX_WS_MESSAGE_BYTES` | `--max-ws-message-bytes` |
294
+
295
+ ```bash
296
+ python -m wsbuilder --max-workers 128 --max-body-bytes 4194304 --shutdown-timeout 5
264
297
  ```
265
298
 
299
+ Asignar los atributos (`server.MAX_REQUEST_BODY_BYTES = ...`) sigue funcionando.
300
+
301
+ ### Apagado ordenado
302
+
303
+ `server.stop()`, `Ctrl+C` y `SIGTERM` (el que envian Docker, systemd o
304
+ Kubernetes) siguen el mismo camino:
305
+
306
+ 1. se deja de aceptar conexiones nuevas;
307
+ 2. las peticiones en curso terminan y se responden con `Connection: close`;
308
+ 3. el servidor espera a esas conexiones hasta `GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS`;
309
+ 4. por ultimo se llama a `app.close()`.
310
+
311
+ `SIGTERM` solo se captura cuando `serve_forever()` corre en el hilo principal;
312
+ el manejador anterior se restaura al terminar. `server.active_connections()`
313
+ devuelve cuantas conexiones siguen abiertas.
314
+
266
315
  ### Ciclo de vida
267
316
 
268
317
  Para levantar el servidor desde otro hilo (tests, embebido en otra app):
@@ -65,6 +65,30 @@ logger.event("server_start", host="0.0.0.0", port=8765)
65
65
 
66
66
  `NDJSONLog` escribe un evento por linea y funciona bien para ingest para herramientas externas.
67
67
 
68
+ ### Mensajes del framework
69
+
70
+ Los avisos y errores del propio framework (handlers que fallan, errores TLS,
71
+ arranque y apagado del servidor) pasan por `wsbuilder.logs`:
72
+
73
+ - sin `enable_logs()`, salen por **stderr** como `[evento] mensaje`; los errores
74
+ incluyen el traceback completo;
75
+ - con `enable_logs()`, cada mensaje se guarda en el NDJSON con `ts`, `level`,
76
+ `event`, `message` y, si hubo excepcion, `error.type`, `error.message` y
77
+ `error.traceback`. Los de nivel `warning` o superior tambien se muestran por
78
+ stderr.
79
+
80
+ ```python
81
+ logger = app.enable_logs(path="logs/wsbuilder.ndjson", console_level="error")
82
+ logger.info("deploy", "version publicada", version="1.0.3")
83
+ logger.exception("pagos", "fallo el cobro", exc)
84
+
85
+ from wsbuilder.logs import console
86
+ console.level = "warning" # sin enable_logs: oculta los mensajes info
87
+ ```
88
+
89
+ Niveles: `debug`, `info`, `warning`, `error`. `console_level=None` deja los
90
+ mensajes solo en el archivo.
91
+
68
92
  ## Docs nativas
69
93
 
70
94
  `App.enable_docs()` publica una vista HTML y un endpoint JSON con `App.describe()`.
@@ -11,11 +11,25 @@ WSBuilder incluye handshake, framing y una clase `WebSocket` para manejar la con
11
11
  idle_timeout=15.0,
12
12
  keepalive_interval=5.0,
13
13
  pong_timeout=3.0,
14
+ max_message_bytes=256 * 1024,
14
15
  )
15
16
  def handler(ws, _request):
16
17
  ...
17
18
  ```
18
19
 
20
+ ### Limites de tamano
21
+
22
+ | Opcion | Por defecto | Efecto |
23
+ | --- | --- | --- |
24
+ | `max_frame_bytes` | `2 MiB` | frame mas grande aceptado |
25
+ | `max_message_bytes` | `2 MiB` | mensaje reensamblado mas grande; tambien limita cada frame |
26
+
27
+ Si un cliente los supera, la conexion se cierra con el codigo `1009`
28
+ (*Message Too Big*) y `recv_frame()` lanza `WebSocketMessageTooBigError`
29
+ (subclase de `WebSocketProtocolError`). Para fijar el limite de todas las
30
+ rutas usa `HTTPServer(..., max_ws_frame_bytes=..., max_ws_message_bytes=...)`;
31
+ el valor de la ruta tiene prioridad sobre el del servidor.
32
+
19
33
  ## Handshake
20
34
 
21
35
  La funcion `handshake_websocket_with_options()` valida:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsbuilder"
7
- version = "1.0.3"
7
+ version = "1.2.0"
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.0.3"
129
+ __version__ = "1.2.0"
130
130
 
131
131
  __all__ = [
132
132
  "App",
@@ -184,16 +184,42 @@ def build_demo_app():
184
184
  return app
185
185
 
186
186
 
187
+ #: CLI flag -> HTTPServer limit keyword, type and help.
188
+ CLI_LIMITS = (
189
+ ("--max-workers", "max_connection_workers", int, "conexiones atendidas a la vez"),
190
+ ("--max-header-bytes", "max_request_header_bytes", int, "tamano maximo de cabeceras"),
191
+ ("--max-body-bytes", "max_request_body_bytes", int, "tamano maximo del body"),
192
+ ("--request-timeout", "request_read_timeout", float, "segundos para leer una peticion"),
193
+ ("--keepalive-timeout", "keepalive_timeout", float, "segundos de espera entre peticiones"),
194
+ ("--max-keepalive-requests", "max_keepalive_requests", int, "peticiones por conexion (0 = sin limite)"),
195
+ ("--shutdown-timeout", "graceful_shutdown_timeout", float, "segundos de espera al apagar"),
196
+ ("--max-ws-message-bytes", "max_ws_message_bytes", int, "tamano maximo de un mensaje WebSocket"),
197
+ )
198
+
199
+
187
200
  def main():
188
201
  parser = argparse.ArgumentParser(
189
202
  description="Levanta un servidor demo HTTP + WebSocket de wsbuilder"
190
203
  )
191
204
  parser.add_argument("--host", default=DEFAULT_HOST, help="Host de escucha")
192
205
  parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Puerto de escucha")
206
+ limits = parser.add_argument_group("limites del servidor (por defecto los de HTTPServer)")
207
+ for flag, option, kind, help_text in CLI_LIMITS:
208
+ metavar = "SEG" if kind is float else "N"
209
+ limits.add_argument(
210
+ flag, dest=option, type=kind, default=None, metavar=metavar, help=help_text
211
+ )
193
212
  args = parser.parse_args()
194
213
 
214
+ server_options = {option: getattr(args, option) for _flag, option, _kind, _help in CLI_LIMITS}
195
215
  app = build_demo_app()
196
- app.run(args.host, args.port)
216
+ try:
217
+ app.run(args.host, args.port, **server_options)
218
+ except ValueError as exc:
219
+ message = str(exc)
220
+ for flag, option, _kind, _help in CLI_LIMITS:
221
+ message = message.replace(option, flag)
222
+ parser.error(message)
197
223
 
198
224
 
199
225
  if __name__ == "__main__":
@@ -9,6 +9,7 @@ from html import escape as html_escape
9
9
  from .constants import DEFAULT_CORS_ALLOW_ORIGIN
10
10
  from .cookies import build_set_cookie, get_cookie
11
11
  from .http import Response
12
+ from .logs import get_log
12
13
  from .tasks import TaskManager
13
14
  from .ws import _normalize_protocols
14
15
 
@@ -576,6 +577,8 @@ class App:
576
577
  on_timeout=None,
577
578
  io_poll_interval=1.0,
578
579
  ping_payload=b"",
580
+ max_frame_bytes=None,
581
+ max_message_bytes=None,
579
582
  ):
580
583
  normalized_path = str(path or "")
581
584
  if not normalized_path.startswith("/") or any(
@@ -592,6 +595,9 @@ class App:
592
595
  )
593
596
  if len(normalized_ping_payload) > 125:
594
597
  raise ValueError("WebSocket ping payload must be at most 125 bytes")
598
+ for name, limit in (("max_frame_bytes", max_frame_bytes), ("max_message_bytes", max_message_bytes)):
599
+ if limit is not None and int(limit) < 1:
600
+ raise ValueError(f"WebSocket {name} must be at least 1 byte")
595
601
 
596
602
  def decorator(func):
597
603
  self.ws_routes[normalized_path] = {
@@ -606,6 +612,8 @@ class App:
606
612
  "on_timeout": on_timeout,
607
613
  "io_poll_interval": float(io_poll_interval or 1.0),
608
614
  "ping_payload": normalized_ping_payload,
615
+ "max_frame_bytes": None if max_frame_bytes is None else int(max_frame_bytes),
616
+ "max_message_bytes": None if max_message_bytes is None else int(max_message_bytes),
609
617
  }
610
618
  return func
611
619
 
@@ -711,7 +719,13 @@ class App:
711
719
  **kwargs,
712
720
  )
713
721
 
714
- def enable_logs(self, path="logs/wsbuilder.ndjson", attr_name="logs", ensure_parent=True):
722
+ def enable_logs(
723
+ self,
724
+ path="logs/wsbuilder.ndjson",
725
+ attr_name="logs",
726
+ ensure_parent=True,
727
+ console_level="warning",
728
+ ):
715
729
  from .logs import install_logs
716
730
 
717
731
  return install_logs(
@@ -719,6 +733,7 @@ class App:
719
733
  path=path,
720
734
  attr_name=attr_name,
721
735
  ensure_parent=ensure_parent,
736
+ console_level=console_level,
722
737
  )
723
738
 
724
739
  def describe(self):
@@ -1302,7 +1317,9 @@ class App:
1302
1317
  except _RouteExecutionError as e:
1303
1318
  return Response.text(e.message, status=e.status)
1304
1319
  except Exception as e:
1305
- print(f"[http] handler error {request.method} {request.path}: {e}")
1320
+ get_log(self).exception(
1321
+ "http", f"handler error {request.method} {request.path}: {e}", e
1322
+ )
1306
1323
  if route.kind == "api":
1307
1324
  headers = {}
1308
1325
  if cors_allow_origin:
@@ -1356,8 +1373,9 @@ class App:
1356
1373
 
1357
1374
  return resp
1358
1375
 
1359
- def run(self, host, port, ssl_context=None):
1376
+ def run(self, host, port, ssl_context=None, **server_options):
1377
+ """Serve the app; ``server_options`` are ``HTTPServer`` limit keywords."""
1360
1378
  from .server import HTTPServer
1361
1379
 
1362
- server = HTTPServer(host, port, self, ssl_context=ssl_context)
1380
+ server = HTTPServer(host, port, self, ssl_context=ssl_context, **server_options)
1363
1381
  server.serve_forever()
@@ -6,6 +6,8 @@ import sqlite3
6
6
  import threading
7
7
  from dataclasses import dataclass
8
8
 
9
+ from .logs import get_log
10
+
9
11
 
10
12
  @dataclass
11
13
  class SQLite3OptimizationConfig:
@@ -267,7 +269,7 @@ class OptimizedDatabase:
267
269
 
268
270
  def _log(self, *parts):
269
271
  if self.debug:
270
- print("[DB]", *parts)
272
+ get_log().info("db", " ".join(str(part) for part in parts))
271
273
 
272
274
  def _get_tx_depth(self) -> int:
273
275
  return getattr(self._tx_depth, "value", 0)
@@ -55,6 +55,7 @@ from .ws import (
55
55
  WebSocket,
56
56
  WebSocketConnectionClosedError,
57
57
  WebSocketFrame,
58
+ WebSocketMessageTooBigError,
58
59
  WebSocketProtocolError,
59
60
  WebSocketReadError,
60
61
  WebSocketReadTimeoutError,
@@ -134,6 +135,7 @@ __all__ = [
134
135
  "WebSocketReadTimeoutError",
135
136
  "WebSocketConnectionClosedError",
136
137
  "WebSocketProtocolError",
138
+ "WebSocketMessageTooBigError",
137
139
  "DEFAULT_CORS_ALLOW_ORIGIN",
138
140
  "MAGIC_WS",
139
141
  "STATUS_MESSAGES",
@@ -4,6 +4,7 @@ from urllib.parse import parse_qsl
4
4
 
5
5
  from .constants import STATUS_MESSAGES
6
6
  from .headers import validate_header_name, validate_header_value
7
+ from .logs import get_log
7
8
 
8
9
  MAX_QUERY_FIELDS = 1024
9
10
  HTTP_0_9 = "HTTP/0.9"
@@ -231,7 +232,7 @@ def send_http_response(conn, response, *, send_body=True, keep_alive=False, vers
231
232
  else:
232
233
  conn.sendall(response.body)
233
234
  except Exception as e:
234
- print(f"[http] send error {status_code}: {e}")
235
+ get_log().warning("http", f"send error {status_code}: {e}")
235
236
  return
236
237
  reason = validate_header_value(
237
238
  response.reason or STATUS_MESSAGES.get(status_code, "Unknown")
@@ -290,7 +291,7 @@ def send_http_response(conn, response, *, send_body=True, keep_alive=False, vers
290
291
  else:
291
292
  conn.sendall(response.body)
292
293
  except Exception as e:
293
- print(f"[http] send error {status_code}: {e}")
294
+ get_log().warning("http", f"send error {status_code}: {e}")
294
295
 
295
296
 
296
297
  def _iter_stream_chunks(source):
@@ -14,6 +14,8 @@ it would desynchronise the decoder for every later stream.
14
14
  import struct
15
15
 
16
16
  from .hpack import Decoder, Encoder, HPACKError
17
+ from .http import Response
18
+ from .logs import get_log
17
19
 
18
20
  #: RFC 9113 section 3.4. A client opens with this exact octet sequence.
19
21
  CONNECTION_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
@@ -239,6 +241,8 @@ class Stream:
239
241
  #: Body the peer's window has no room for yet.
240
242
  self.pending_output = bytearray()
241
243
  self.pending_end_stream = False
244
+ #: Set once the body outgrew the server limit and a 413 went out.
245
+ self.body_rejected = False
242
246
 
243
247
  @property
244
248
  def closed(self):
@@ -317,8 +321,12 @@ class Http2Connection:
317
321
  exercised in tests without a socket.
318
322
  """
319
323
 
320
- def __init__(self, conn, app, *, tls=None, client_address=("", 0), max_streams=100):
324
+ def __init__(
325
+ self, conn, app, *, tls=None, client_address=("", 0), max_streams=100, max_body_bytes=None
326
+ ):
321
327
  self.conn = conn
328
+ #: Largest request body buffered per stream; None leaves it unbounded.
329
+ self.max_body_bytes = None if max_body_bytes is None else int(max_body_bytes)
322
330
  self.app = app
323
331
  self.tls = tls or {}
324
332
  self.client_address = client_address
@@ -577,6 +585,8 @@ class Http2Connection:
577
585
  stream.open_remote(end_stream)
578
586
  if end_stream:
579
587
  self._dispatch(stream)
588
+ elif self._declared_length_exceeds_limit(headers):
589
+ self._reject_body(stream)
580
590
 
581
591
  def _on_data(self, frame):
582
592
  self._require_stream_id(frame, False)
@@ -598,6 +608,15 @@ class Http2Connection:
598
608
  raise StreamError("DATA on a stream that is not open", frame.stream_id,
599
609
  ERROR_STREAM_CLOSED)
600
610
  stream.inbound.consume(received)
611
+ if stream.body_rejected:
612
+ # The 413 is already on its way; the rest of the body is dropped
613
+ # and its window is not handed back.
614
+ if frame.has(FLAG_END_STREAM):
615
+ stream.end_remote()
616
+ return
617
+ if self.max_body_bytes is not None and len(stream.body) + len(payload) > self.max_body_bytes:
618
+ self._reject_body(stream)
619
+ return
601
620
  stream.body.extend(payload)
602
621
  if received:
603
622
  self._send(Frame(FRAME_WINDOW_UPDATE, 0, stream.id, struct.pack(">I", received)))
@@ -606,6 +625,31 @@ class Http2Connection:
606
625
  stream.end_remote()
607
626
  self._dispatch(stream)
608
627
 
628
+ def _declared_length_exceeds_limit(self, headers):
629
+ if self.max_body_bytes is None:
630
+ return False
631
+ for name, value in headers:
632
+ if name == "content-length":
633
+ try:
634
+ return int(value) > self.max_body_bytes
635
+ except ValueError:
636
+ return False
637
+ return False
638
+
639
+ def _reject_body(self, stream):
640
+ """Answer 413 before the body is complete, then stop the upload.
641
+
642
+ RFC 9113 section 8.1 lets a server respond early and reset the
643
+ request stream with NO_ERROR so the client stops sending.
644
+ """
645
+ stream.body_rejected = True
646
+ stream.body = bytearray()
647
+ self.send_response(stream, Response.text("Payload Too Large", status=413))
648
+ if not stream.pending_output:
649
+ stream.state = CLOSED
650
+ stream.reset_code = ERROR_NO_ERROR
651
+ self.send_rst_stream(stream.id, ERROR_NO_ERROR)
652
+
609
653
  # -- request handling ----------------------------------------------
610
654
 
611
655
  def _dispatch(self, stream):
@@ -632,7 +676,7 @@ class Http2Connection:
632
676
  try:
633
677
  response = self.app.dispatch(request)
634
678
  except Exception as exc: # noqa: BLE001 - reported on the stream
635
- print(f"[http2] handler error on stream {stream.id}: {exc}")
679
+ get_log(self.app).exception("http2", f"handler error on stream {stream.id}: {exc}", exc)
636
680
  raise StreamError("handler failed", stream.id, ERROR_INTERNAL_ERROR) from exc
637
681
  self.send_response(stream, response, send_body=request.method != "HEAD")
638
682
 
@@ -43,6 +43,8 @@ SETTINGS_QPACK_BLOCKED_STREAMS = 0x07
43
43
  #: RFC 9220: lets a client send ``CONNECT`` with a ``:protocol`` pseudo-header,
44
44
  #: which is how WebSocket runs over HTTP/3.
45
45
  SETTINGS_ENABLE_CONNECT_PROTOCOL = 0x08
46
+ #: Largest header block we accept, advertised in SETTINGS.
47
+ MAX_FIELD_SECTION_SIZE = 65536
46
48
 
47
49
  H3_NO_ERROR = 0x0100
48
50
  H3_GENERAL_PROTOCOL_ERROR = 0x0101
@@ -171,10 +173,17 @@ def validate_request_headers(headers):
171
173
 
172
174
 
173
175
  class RequestStream:
174
- """One request/response exchange on a bidirectional QUIC stream."""
176
+ """One request/response exchange on a bidirectional QUIC stream.
175
177
 
176
- def __init__(self, stream_id):
178
+ With ``max_body_bytes`` set, a body (or a half-received frame) that
179
+ outgrows it is dropped and ``body_rejected`` is set so the server can
180
+ answer 413; the rest of the stream is then discarded.
181
+ """
182
+
183
+ def __init__(self, stream_id, max_body_bytes=None):
177
184
  self.id = int(stream_id)
185
+ self.max_body_bytes = None if max_body_bytes is None else int(max_body_bytes)
186
+ self.body_rejected = False
178
187
  self.buffer = bytearray()
179
188
  self.headers = None
180
189
  self.body = bytearray()
@@ -186,17 +195,37 @@ class RequestStream:
186
195
 
187
196
  def feed(self, data, fin=False):
188
197
  """Add stream bytes; returns True once the request is complete."""
198
+ if self.body_rejected:
199
+ self.finished = self.finished or bool(fin)
200
+ return False
189
201
  self.buffer.extend(data)
190
202
  frames, tail = parse_frames(self.buffer)
191
203
  self.buffer = bytearray(tail)
192
204
  for frame_type, payload in frames:
193
205
  self._on_frame(frame_type, payload)
206
+ if self.body_rejected:
207
+ return False
208
+ if self._buffer_limit() is not None and len(self.buffer) > self._buffer_limit():
209
+ # A frame that has not finished arriving can still outgrow memory.
210
+ self._reject_body()
211
+ return False
194
212
  if fin:
195
213
  self.finished = True
196
214
  if self.buffer:
197
215
  raise H3Error("stream ended mid-frame", H3_FRAME_ERROR)
198
216
  return self.finished and self.headers is not None
199
217
 
218
+ def _buffer_limit(self):
219
+ if self.max_body_bytes is None:
220
+ return None
221
+ # Room for one whole field section or body, plus a frame header.
222
+ return max(self.max_body_bytes, MAX_FIELD_SECTION_SIZE) + 16
223
+
224
+ def _reject_body(self):
225
+ self.body_rejected = True
226
+ self.body = bytearray()
227
+ self.buffer = bytearray()
228
+
200
229
  def _on_frame(self, frame_type, payload):
201
230
  if frame_type == FRAME_HEADERS:
202
231
  try:
@@ -212,6 +241,13 @@ class RequestStream:
212
241
  if self.headers is None:
213
242
  # Section 4.1: a request opens with HEADERS, always.
214
243
  raise H3Error("DATA before HEADERS", H3_FRAME_UNEXPECTED)
244
+ if (
245
+ self.max_body_bytes is not None
246
+ and self.websocket is None
247
+ and len(self.body) + len(payload) > self.max_body_bytes
248
+ ):
249
+ self._reject_body()
250
+ return
215
251
  self.body.extend(payload)
216
252
  return
217
253
  if frame_type in (FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID, FRAME_CANCEL_PUSH):
@@ -306,7 +342,7 @@ def build_control_stream(settings=None):
306
342
  values = {
307
343
  SETTINGS_QPACK_MAX_TABLE_CAPACITY: 0,
308
344
  SETTINGS_QPACK_BLOCKED_STREAMS: 0,
309
- SETTINGS_MAX_FIELD_SECTION_SIZE: 65536,
345
+ SETTINGS_MAX_FIELD_SECTION_SIZE: MAX_FIELD_SECTION_SIZE,
310
346
  SETTINGS_ENABLE_CONNECT_PROTOCOL: 1,
311
347
  }
312
348
  if settings: