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.
- {wsbuilder-1.0.3/src/wsbuilder.egg-info → wsbuilder-1.2.0}/PKG-INFO +1 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/advanced.md +9 -5
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/app-http.md +53 -4
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/observability.md +24 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/websocket.md +14 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/pyproject.toml +1 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/__init__.py +1 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/__main__.py +27 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/app.py +22 -4
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/db_replicas.py +3 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/framework.py +2 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http.py +3 -2
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http2.py +46 -2
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3.py +39 -3
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3_server.py +34 -12
- wsbuilder-1.2.0/src/wsbuilder/logs.py +208 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/orm.py +3 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/connection.py +104 -17
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/server.py +123 -15
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ws.py +44 -9
- {wsbuilder-1.0.3 → wsbuilder-1.2.0/src/wsbuilder.egg-info}/PKG-INFO +1 -1
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http2.py +54 -2
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3.py +29 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_server.py +120 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_streaming.py +44 -1
- wsbuilder-1.2.0/tests/test_logs.py +226 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_server.py +208 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_ws.py +47 -0
- wsbuilder-1.0.3/src/wsbuilder/logs.py +0 -67
- wsbuilder-1.0.3/tests/test_logs.py +0 -101
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/LICENSE +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/MANIFEST.in +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/README.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/404.html +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/CNAME +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/ai-predict.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/api-map.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/architecture.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/assets/wsbuilder-mark.svg +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/beginners.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/cache-security.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/cache.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/capabilities.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/data-orm.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/dns.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/full-project.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/getting-started.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/help/index.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/http-versions.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/http.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/ia.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/index.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/install.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/intermediate.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/mdns.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/network-edge.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/observability-tasks.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/persistence.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/proxy.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/reference/index.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/security.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/stylesheets/extra.css +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/tasks.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/docs/tls-certificates.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/.gitignore +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/README.md +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/app.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/pyproject.toml +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/examples/full-project/upstream.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/mkdocs.yml +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/setup.cfg +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/cache.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/caches.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/constants.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/cookies.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/dns.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/headers.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/hpack.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http1.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/http3_websocket.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ia.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/mdns.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/metrics.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/pki.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/predicts.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/proxyi.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/qpack.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/__init__.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/address.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/crypto.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/frames.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/packet.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/recovery.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/tls.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/quic/varint.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/security.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/tasks.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder/ws_demo.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/SOURCES.txt +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/entry_points.txt +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/requires.txt +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/src/wsbuilder.egg-info/top_level.txt +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_app_threads.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_cache.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_caches.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_contributing_policy.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_demo_monitor.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_dns.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_docs.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_documentation_files.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_headers_cookies.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_hpack.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http1.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_http3_streaming_udp.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_ia.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_mdns.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_metrics.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_orm.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_orm_replicas.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_pki.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_pr_identity_policy.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_predicts.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_proxyi.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_address.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_crypto.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_protocol.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_recovery.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_quic_tls.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_security.py +0 -0
- {wsbuilder-1.0.3 → wsbuilder-1.2.0}/tests/test_tasks.py +0 -0
|
@@ -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(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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(
|
|
262
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
345
|
+
SETTINGS_MAX_FIELD_SECTION_SIZE: MAX_FIELD_SECTION_SIZE,
|
|
310
346
|
SETTINGS_ENABLE_CONNECT_PROTOCOL: 1,
|
|
311
347
|
}
|
|
312
348
|
if settings:
|