hexcore 6.0.2__tar.gz → 6.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.
- {hexcore-6.0.2/hexcore.egg-info → hexcore-6.2.0}/PKG-INFO +39 -2
- {hexcore-6.0.2 → hexcore-6.2.0}/README.md +38 -1
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/fastapi.py +2 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/app.py +46 -6
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/health.py +77 -14
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/middlewares.py +28 -0
- {hexcore-6.0.2 → hexcore-6.2.0/hexcore.egg-info}/PKG-INFO +39 -2
- {hexcore-6.0.2 → hexcore-6.2.0}/pyproject.toml +1 -1
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_health.py +111 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_middlewares_and_routing.py +54 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/LICENSE +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/MANIFEST.in +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/__main__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/_deprecation.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/scheduler.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/cursor.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/errors.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/config.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/cqrs.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/base.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/context.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/cron.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/resolution.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/events.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/services.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/uow.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/cqrs.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/exception_handlers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/lifespan.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/rate_limit.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/routing.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/streaming.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/cron_sql.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/scopes.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/runner.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/py.typed +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/sql.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/fakes.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/fixtures.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/helpers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/types.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/SOURCES.txt +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/requires.txt +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/scripts/__init__.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/scripts/main.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/setup.cfg +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/conftest.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_app_and_lifespan.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_cqrs_providers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_exception_handlers.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_rate_limit.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_streaming.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_basic.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_celery_event_loop.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_config_loading.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_consumer_optional_event_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs_factory.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs_middlewares.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cron_sql.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_deprecations.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_documentation_examples.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_dotted_resolution.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_facades.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_handler_registry.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_lock_error_policy.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_optional_dependencies.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_postgres_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_postgres_lock.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_query_cursor_and_endpoint.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_redis_bus.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_redis_lock.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_scheduler.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_scheduler_catchup.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_smart_routing.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_sql_session_layer.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_task_queues_adapters.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_testing_utilities.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_uow_session_regression.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_use_cases_query.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_worker_bus_integration.py +0 -0
- {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_worker_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version: 6.0
|
|
3
|
+
Version: 6.2.0
|
|
4
4
|
Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
|
|
5
5
|
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -155,7 +155,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
|
|
|
155
155
|
| Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
|
|
156
156
|
| Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
|
|
157
157
|
| Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
|
|
158
|
-
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
|
|
158
|
+
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
|
|
159
159
|
| Rate limiting | `hx.rate_limit()` | `api` |
|
|
160
160
|
| SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
|
|
161
161
|
| Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
|
|
@@ -308,6 +308,36 @@ register_health_routes(app, probes=[
|
|
|
308
308
|
])
|
|
309
309
|
```
|
|
310
310
|
|
|
311
|
+
#### Si tu app ya publica su propio `/health`
|
|
312
|
+
|
|
313
|
+
Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
|
|
314
|
+
respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
|
|
315
|
+
la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
register_health_routes(app, liveness=False) # sólo /health/ready
|
|
319
|
+
register_health_routes(app, liveness=False, readiness_path="/_ready")
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
|
|
323
|
+
renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
|
|
324
|
+
orquestador:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
register_health_routes(
|
|
328
|
+
app,
|
|
329
|
+
response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Lo mismo desde `create_app`, sin apagar la feature entera:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
|
|
337
|
+
|
|
338
|
+
app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
|
|
339
|
+
```
|
|
340
|
+
|
|
311
341
|
### Rate limiting
|
|
312
342
|
|
|
313
343
|
```python
|
|
@@ -331,8 +361,11 @@ rate_limit(10, 60, on_backend_error="deny")
|
|
|
331
361
|
### Request-id correlacionado
|
|
332
362
|
|
|
333
363
|
```python
|
|
364
|
+
import logging
|
|
365
|
+
|
|
334
366
|
from hexcore.fastapi import get_request_id, install_request_id_logging
|
|
335
367
|
|
|
368
|
+
logging.basicConfig(level=logging.INFO) # primero: configurá el logging
|
|
336
369
|
install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
|
|
337
370
|
```
|
|
338
371
|
|
|
@@ -341,6 +374,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
|
|
|
341
374
|
lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
|
|
342
375
|
correlaciona nada.
|
|
343
376
|
|
|
377
|
+
> **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
|
|
378
|
+
> existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
|
|
379
|
+
> llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
|
|
380
|
+
|
|
344
381
|
### Streaming: SSE, WebSocket y límite de conexiones
|
|
345
382
|
|
|
346
383
|
```python
|
|
@@ -110,7 +110,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
|
|
|
110
110
|
| Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
|
|
111
111
|
| Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
|
|
112
112
|
| Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
|
|
113
|
-
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
|
|
113
|
+
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
|
|
114
114
|
| Rate limiting | `hx.rate_limit()` | `api` |
|
|
115
115
|
| SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
|
|
116
116
|
| Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
|
|
@@ -263,6 +263,36 @@ register_health_routes(app, probes=[
|
|
|
263
263
|
])
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
+
#### Si tu app ya publica su propio `/health`
|
|
267
|
+
|
|
268
|
+
Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
|
|
269
|
+
respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
|
|
270
|
+
la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
register_health_routes(app, liveness=False) # sólo /health/ready
|
|
274
|
+
register_health_routes(app, liveness=False, readiness_path="/_ready")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
|
|
278
|
+
renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
|
|
279
|
+
orquestador:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
register_health_routes(
|
|
283
|
+
app,
|
|
284
|
+
response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
|
|
285
|
+
)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Lo mismo desde `create_app`, sin apagar la feature entera:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
|
|
292
|
+
|
|
293
|
+
app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
|
|
294
|
+
```
|
|
295
|
+
|
|
266
296
|
### Rate limiting
|
|
267
297
|
|
|
268
298
|
```python
|
|
@@ -286,8 +316,11 @@ rate_limit(10, 60, on_backend_error="deny")
|
|
|
286
316
|
### Request-id correlacionado
|
|
287
317
|
|
|
288
318
|
```python
|
|
319
|
+
import logging
|
|
320
|
+
|
|
289
321
|
from hexcore.fastapi import get_request_id, install_request_id_logging
|
|
290
322
|
|
|
323
|
+
logging.basicConfig(level=logging.INFO) # primero: configurá el logging
|
|
291
324
|
install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
|
|
292
325
|
```
|
|
293
326
|
|
|
@@ -296,6 +329,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
|
|
|
296
329
|
lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
|
|
297
330
|
correlaciona nada.
|
|
298
331
|
|
|
332
|
+
> **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
|
|
333
|
+
> existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
|
|
334
|
+
> llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
|
|
335
|
+
|
|
299
336
|
### Streaming: SSE, WebSocket y límite de conexiones
|
|
300
337
|
|
|
301
338
|
```python
|
|
@@ -21,6 +21,7 @@ _EXPORTS: dict[str, tuple[str, str]] = {
|
|
|
21
21
|
# ── App y lifespan ────────────────────────────────────────────────────────
|
|
22
22
|
"create_app": ("hexcore.infrastructure.api.app", "create_app"),
|
|
23
23
|
"AppFeatures": ("hexcore.infrastructure.api.app", "AppFeatures"),
|
|
24
|
+
"HealthRoutes": ("hexcore.infrastructure.api.app", "HealthRoutes"),
|
|
24
25
|
"build_lifespan": ("hexcore.infrastructure.api.lifespan", "build_lifespan"),
|
|
25
26
|
"StartupStep": ("hexcore.infrastructure.api.lifespan", "StartupStep"),
|
|
26
27
|
"CallableStep": ("hexcore.infrastructure.api.lifespan", "CallableStep"),
|
|
@@ -98,6 +99,7 @@ _EXPORTS: dict[str, tuple[str, str]] = {
|
|
|
98
99
|
"HealthReport": ("hexcore.infrastructure.api.health", "HealthReport"),
|
|
99
100
|
"DependencyReport": ("hexcore.infrastructure.api.health", "DependencyReport"),
|
|
100
101
|
"Probe": ("hexcore.infrastructure.api.health", "Probe"),
|
|
102
|
+
"ResponseFactory": ("hexcore.infrastructure.api.health", "ResponseFactory"),
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
__all__ = sorted(_EXPORTS)
|
|
@@ -15,11 +15,29 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from .exception_handlers import register_exception_handlers
|
|
18
|
-
from .health import Probe, register_health_routes
|
|
18
|
+
from .health import Probe, ResponseFactory, register_health_routes
|
|
19
19
|
from .middlewares import RequestIDMiddleware, TimingMiddleware
|
|
20
20
|
from .routing import MountableRouter, mount_routers
|
|
21
21
|
|
|
22
|
-
__all__ = ["AppFeatures", "create_app"]
|
|
22
|
+
__all__ = ["AppFeatures", "HealthRoutes", "create_app"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HealthRoutes(BaseModel):
|
|
26
|
+
"""
|
|
27
|
+
Qué rutas de health cablea `create_app()`, y con qué forma.
|
|
28
|
+
|
|
29
|
+
Existe para que una app **ya publicada** pueda adoptar la readiness sin renunciar a
|
|
30
|
+
su `/health` ni al cliente tipado generado desde su OpenAPI. Los argumentos son los
|
|
31
|
+
de `register_health_routes`, agrupados para no engordar la firma de `create_app`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
35
|
+
|
|
36
|
+
path: str = "/health"
|
|
37
|
+
liveness: bool = True
|
|
38
|
+
readiness: bool = True
|
|
39
|
+
readiness_path: str | None = None
|
|
40
|
+
response_factory: ResponseFactory | None = None
|
|
23
41
|
|
|
24
42
|
|
|
25
43
|
class AppFeatures(BaseModel):
|
|
@@ -41,8 +59,16 @@ class AppFeatures(BaseModel):
|
|
|
41
59
|
exception_handlers: bool = True
|
|
42
60
|
"""Mapeo de excepciones de dominio a HTTP (F5)."""
|
|
43
61
|
|
|
44
|
-
health: bool = True
|
|
45
|
-
"""
|
|
62
|
+
health: bool | HealthRoutes = True
|
|
63
|
+
"""
|
|
64
|
+
Rutas `/health` y `/health/ready` (F16).
|
|
65
|
+
|
|
66
|
+
Acepta también un `HealthRoutes` para adoptarlas **por partes**: una app que ya
|
|
67
|
+
publica su propio `/health` puede quedarse con la readiness, que es la que no se
|
|
68
|
+
escribe a mano, sin apagar la feature entera::
|
|
69
|
+
|
|
70
|
+
AppFeatures(health=HealthRoutes(liveness=False))
|
|
71
|
+
"""
|
|
46
72
|
|
|
47
73
|
|
|
48
74
|
def create_app(
|
|
@@ -65,7 +91,8 @@ def create_app(
|
|
|
65
91
|
features: Qué cablear. Ver `AppFeatures`.
|
|
66
92
|
routers: Routers a montar. Acepta `APIRouter` o `(APIRouter, kwargs)` (F13).
|
|
67
93
|
health_probes: Sondas para `/health/ready`. Por defecto, las deducidas de la
|
|
68
|
-
configuración (F16).
|
|
94
|
+
configuración (F16). Qué rutas se registran y con qué forma se controla con
|
|
95
|
+
`AppFeatures(health=HealthRoutes(...))`.
|
|
69
96
|
exception_mapping: Excepciones extra a mapear, fusionadas con el default (F5).
|
|
70
97
|
**fastapi_kwargs: Se pasan tal cual a `FastAPI` (`title`, `version`,
|
|
71
98
|
`docs_url`, `openapi_tags`…). Lo que pases gana sobre los defaults derivados
|
|
@@ -99,7 +126,20 @@ def create_app(
|
|
|
99
126
|
register_exception_handlers(app, mapping=exception_mapping)
|
|
100
127
|
|
|
101
128
|
if resolved_features.health:
|
|
102
|
-
|
|
129
|
+
health_routes = (
|
|
130
|
+
resolved_features.health
|
|
131
|
+
if isinstance(resolved_features.health, HealthRoutes)
|
|
132
|
+
else HealthRoutes()
|
|
133
|
+
)
|
|
134
|
+
register_health_routes(
|
|
135
|
+
app,
|
|
136
|
+
probes=health_probes,
|
|
137
|
+
path=health_routes.path,
|
|
138
|
+
liveness=health_routes.liveness,
|
|
139
|
+
readiness=health_routes.readiness,
|
|
140
|
+
readiness_path=health_routes.readiness_path,
|
|
141
|
+
response_factory=health_routes.response_factory,
|
|
142
|
+
)
|
|
103
143
|
|
|
104
144
|
if routers:
|
|
105
145
|
mount_routers(app, list(routers))
|
|
@@ -26,6 +26,7 @@ __all__ = [
|
|
|
26
26
|
"DependencyReport",
|
|
27
27
|
"HealthReport",
|
|
28
28
|
"Probe",
|
|
29
|
+
"ResponseFactory",
|
|
29
30
|
"check_health",
|
|
30
31
|
"register_health_routes",
|
|
31
32
|
"default_probes",
|
|
@@ -56,6 +57,11 @@ class HealthReport(BaseModel):
|
|
|
56
57
|
return 503 if self.status == "down" else 200
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
#: Adapta el cuerpo de las rutas de health a la forma que ya publica una app. Recibe el
|
|
61
|
+
#: informe y devuelve lo que haya que serializar.
|
|
62
|
+
ResponseFactory = t.Callable[[HealthReport], t.Any]
|
|
63
|
+
|
|
64
|
+
|
|
59
65
|
class Probe(t.NamedTuple):
|
|
60
66
|
"""Una sonda con nombre y timeout propio."""
|
|
61
67
|
|
|
@@ -231,6 +237,10 @@ def register_health_routes(
|
|
|
231
237
|
*,
|
|
232
238
|
path: str = "/health",
|
|
233
239
|
probes: t.Sequence[Probe] | None = None,
|
|
240
|
+
liveness: bool = True,
|
|
241
|
+
readiness: bool = True,
|
|
242
|
+
readiness_path: str | None = None,
|
|
243
|
+
response_factory: ResponseFactory | None = None,
|
|
234
244
|
) -> None:
|
|
235
245
|
"""
|
|
236
246
|
Registra `GET {path}` (liveness) y `GET {path}/ready` (readiness).
|
|
@@ -238,18 +248,71 @@ def register_health_routes(
|
|
|
238
248
|
- `{path}` responde 200 sin I/O. Apuntá aquí el liveness probe.
|
|
239
249
|
- `{path}/ready` sondea las dependencias y responde **503** si algo crítico falla,
|
|
240
250
|
con el detalle y la latencia por dependencia. Apuntá aquí el readiness probe.
|
|
251
|
+
|
|
252
|
+
Las dos rutas se registran por separado a propósito. Una app que **ya publica**
|
|
253
|
+
`/health` con su propia forma —y con un cliente tipado generado desde su OpenAPI— no
|
|
254
|
+
puede aceptar la forma de `HealthReport` sin romper el contrato, pero sí quiere la
|
|
255
|
+
readiness, que es la parte que no se puede escribir a mano en cinco minutos::
|
|
256
|
+
|
|
257
|
+
register_health_routes(app, liveness=False) # sólo /health/ready
|
|
258
|
+
|
|
259
|
+
Y si lo que hace falta es conservar la forma del cuerpo, `response_factory` la
|
|
260
|
+
adapta sin renunciar a las sondas::
|
|
261
|
+
|
|
262
|
+
register_health_routes(
|
|
263
|
+
app,
|
|
264
|
+
response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
app: La app donde registrar.
|
|
269
|
+
path: Ruta del liveness. También la base del readiness, salvo que se dé
|
|
270
|
+
`readiness_path`.
|
|
271
|
+
probes: Sondas del readiness. Por defecto, las de `default_probes()`.
|
|
272
|
+
liveness: Registrar `GET {path}`. Poné `False` si ya publicás el tuyo.
|
|
273
|
+
readiness: Registrar el readiness.
|
|
274
|
+
readiness_path: Ruta del readiness. Por defecto ``f"{path}/ready"``.
|
|
275
|
+
response_factory: Si se da, se le pasa el `HealthReport` y lo que devuelva es el
|
|
276
|
+
cuerpo de la respuesta. El **status code** lo sigue decidiendo el informe
|
|
277
|
+
(503 si algo crítico está caído), salvo que devuelvas una `Response` propia,
|
|
278
|
+
en cuyo caso el status es cosa tuya.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
ValueError: Si se desactivan las dos rutas. Una llamada que no registra nada es
|
|
282
|
+
un error de configuración, y descubrirlo por silencio cuesta un incidente.
|
|
241
283
|
"""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
report
|
|
254
|
-
|
|
255
|
-
|
|
284
|
+
if not liveness and not readiness:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"register_health_routes(liveness=False, readiness=False) no registra nada. "
|
|
287
|
+
"Si no querés ninguna de las dos rutas, no llames a la función."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Sin factory se declara `HealthReport` para que el OpenAPI documente la forma; con
|
|
291
|
+
# factory el cuerpo es lo que devuelva el usuario y no hay modelo que prometer.
|
|
292
|
+
response_model = None if response_factory is not None else HealthReport
|
|
293
|
+
|
|
294
|
+
def render(report: HealthReport) -> t.Any:
|
|
295
|
+
return report if response_factory is None else response_factory(report)
|
|
296
|
+
|
|
297
|
+
if liveness:
|
|
298
|
+
@app.get(
|
|
299
|
+
path,
|
|
300
|
+
tags=["health"],
|
|
301
|
+
summary="Liveness: el proceso responde",
|
|
302
|
+
response_model=response_model,
|
|
303
|
+
)
|
|
304
|
+
async def health() -> t.Any:
|
|
305
|
+
return render(await check_health(deep=False))
|
|
306
|
+
|
|
307
|
+
if readiness:
|
|
308
|
+
@app.get(
|
|
309
|
+
readiness_path or f"{path}/ready",
|
|
310
|
+
tags=["health"],
|
|
311
|
+
summary="Readiness: las dependencias responden",
|
|
312
|
+
response_model=response_model,
|
|
313
|
+
responses={503: {"description": "Alguna dependencia crítica no responde"}},
|
|
314
|
+
)
|
|
315
|
+
async def health_ready(response: Response) -> t.Any:
|
|
316
|
+
report = await check_health(deep=True, probes=probes)
|
|
317
|
+
response.status_code = report.http_status
|
|
318
|
+
return render(report)
|
|
@@ -12,6 +12,7 @@ import logging
|
|
|
12
12
|
import time
|
|
13
13
|
import typing as t
|
|
14
14
|
import uuid
|
|
15
|
+
import warnings
|
|
15
16
|
from contextvars import ContextVar
|
|
16
17
|
|
|
17
18
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
@@ -123,11 +124,25 @@ def install_request_id_logging(
|
|
|
123
124
|
"""
|
|
124
125
|
Instala el filtro de request-id en los handlers de un logger.
|
|
125
126
|
|
|
127
|
+
**Configurá el logging primero.** Esta función instrumenta los handlers que
|
|
128
|
+
**ya existen**; si no hay ninguno, no hay nada que instrumentar y no hace nada::
|
|
129
|
+
|
|
130
|
+
logging.basicConfig(level=logging.INFO) # primero
|
|
131
|
+
install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
|
|
132
|
+
|
|
133
|
+
Al revés no falla, pero deja el logging sin configurar y el request-id sin
|
|
134
|
+
aparecer. Por eso el caso "sin handlers" emite un `RuntimeWarning` en vez de
|
|
135
|
+
devolver en silencio: el síntoma sería "no veo el request-id en los logs", que no
|
|
136
|
+
apunta a ninguna causa.
|
|
137
|
+
|
|
126
138
|
Args:
|
|
127
139
|
logger: El logger a instrumentar. Por defecto, el root logger.
|
|
128
140
|
fmt: Si se da, se aplica como formato a los handlers del logger. Usá
|
|
129
141
|
``%(request_id)s`` en él. Si es None, no se toca el formato: el filtro
|
|
130
142
|
deja el atributo disponible para el formato que ya tengas.
|
|
143
|
+
|
|
144
|
+
Warns:
|
|
145
|
+
RuntimeWarning: Si el logger de destino no tiene ningún handler.
|
|
131
146
|
"""
|
|
132
147
|
target = logger or logging.getLogger()
|
|
133
148
|
log_filter = RequestIDLogFilter()
|
|
@@ -135,6 +150,19 @@ def install_request_id_logging(
|
|
|
135
150
|
# El filtro va en los handlers, no en el logger: un filtro de logger no se aplica
|
|
136
151
|
# a los registros que suben por propagación desde loggers hijos.
|
|
137
152
|
handlers = target.handlers or logging.getLogger().handlers
|
|
153
|
+
if not handlers:
|
|
154
|
+
# Un no-op silencioso en una utilidad de *observabilidad* es especialmente malo:
|
|
155
|
+
# el usuario descubre que no funcionó cuando necesita correlacionar un incidente.
|
|
156
|
+
warnings.warn(
|
|
157
|
+
f"install_request_id_logging: el logger {target.name!r} no tiene handlers, "
|
|
158
|
+
"así que no hay nada que instrumentar y esta llamada no hizo nada. "
|
|
159
|
+
"Configurá el logging primero (p. ej. logging.basicConfig(...)) y llamá "
|
|
160
|
+
"después.",
|
|
161
|
+
RuntimeWarning,
|
|
162
|
+
stacklevel=2,
|
|
163
|
+
)
|
|
164
|
+
return
|
|
165
|
+
|
|
138
166
|
for handler in handlers:
|
|
139
167
|
if not any(isinstance(f, RequestIDLogFilter) for f in handler.filters):
|
|
140
168
|
handler.addFilter(log_filter)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version: 6.0
|
|
3
|
+
Version: 6.2.0
|
|
4
4
|
Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
|
|
5
5
|
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -155,7 +155,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
|
|
|
155
155
|
| Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
|
|
156
156
|
| Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
|
|
157
157
|
| Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
|
|
158
|
-
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
|
|
158
|
+
| Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
|
|
159
159
|
| Rate limiting | `hx.rate_limit()` | `api` |
|
|
160
160
|
| SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
|
|
161
161
|
| Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
|
|
@@ -308,6 +308,36 @@ register_health_routes(app, probes=[
|
|
|
308
308
|
])
|
|
309
309
|
```
|
|
310
310
|
|
|
311
|
+
#### Si tu app ya publica su propio `/health`
|
|
312
|
+
|
|
313
|
+
Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
|
|
314
|
+
respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
|
|
315
|
+
la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
register_health_routes(app, liveness=False) # sólo /health/ready
|
|
319
|
+
register_health_routes(app, liveness=False, readiness_path="/_ready")
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
|
|
323
|
+
renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
|
|
324
|
+
orquestador:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
register_health_routes(
|
|
328
|
+
app,
|
|
329
|
+
response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Lo mismo desde `create_app`, sin apagar la feature entera:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
|
|
337
|
+
|
|
338
|
+
app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
|
|
339
|
+
```
|
|
340
|
+
|
|
311
341
|
### Rate limiting
|
|
312
342
|
|
|
313
343
|
```python
|
|
@@ -331,8 +361,11 @@ rate_limit(10, 60, on_backend_error="deny")
|
|
|
331
361
|
### Request-id correlacionado
|
|
332
362
|
|
|
333
363
|
```python
|
|
364
|
+
import logging
|
|
365
|
+
|
|
334
366
|
from hexcore.fastapi import get_request_id, install_request_id_logging
|
|
335
367
|
|
|
368
|
+
logging.basicConfig(level=logging.INFO) # primero: configurá el logging
|
|
336
369
|
install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
|
|
337
370
|
```
|
|
338
371
|
|
|
@@ -341,6 +374,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
|
|
|
341
374
|
lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
|
|
342
375
|
correlaciona nada.
|
|
343
376
|
|
|
377
|
+
> **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
|
|
378
|
+
> existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
|
|
379
|
+
> llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
|
|
380
|
+
|
|
344
381
|
### Streaming: SSE, WebSocket y límite de conexiones
|
|
345
382
|
|
|
346
383
|
```python
|
|
@@ -218,3 +218,114 @@ def test_health_report_is_serializable():
|
|
|
218
218
|
report = HealthReport(status="ok")
|
|
219
219
|
|
|
220
220
|
assert report.model_dump()["status"] == "ok"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── R5: adopción por partes en una app que ya publica su /health ───────────────
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_readiness_can_be_registered_without_touching_the_published_liveness():
|
|
227
|
+
"""
|
|
228
|
+
El caso que dejó F16 sin adoptar: la app ya publica `/health` con su propia forma y
|
|
229
|
+
un cliente tipado generado desde el OpenAPI. Antes había que apagar la feature
|
|
230
|
+
entera, y con ella se perdía la readiness, que es la parte valiosa.
|
|
231
|
+
"""
|
|
232
|
+
app = FastAPI()
|
|
233
|
+
|
|
234
|
+
@app.get("/health")
|
|
235
|
+
async def mi_health() -> dict[str, str]:
|
|
236
|
+
return {"estado": "vivo"} # el contrato ya publicado, intacto
|
|
237
|
+
|
|
238
|
+
register_health_routes(app, liveness=False, probes=[Probe("sql", _boom)])
|
|
239
|
+
|
|
240
|
+
with TestClient(app) as client:
|
|
241
|
+
assert client.get("/health").json() == {"estado": "vivo"}
|
|
242
|
+
assert client.get("/health/ready").status_code == 503
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_liveness_can_be_registered_alone():
|
|
246
|
+
app = FastAPI()
|
|
247
|
+
register_health_routes(app, readiness=False, probes=[Probe("sql", _boom)])
|
|
248
|
+
|
|
249
|
+
with TestClient(app) as client:
|
|
250
|
+
assert client.get("/health").status_code == 200
|
|
251
|
+
assert client.get("/health/ready").status_code == 404
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_disabling_both_routes_is_an_error_not_a_silent_noop():
|
|
255
|
+
app = FastAPI()
|
|
256
|
+
|
|
257
|
+
with pytest.raises(ValueError, match="no registra nada"):
|
|
258
|
+
register_health_routes(app, liveness=False, readiness=False)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_readiness_path_can_be_set_independently():
|
|
262
|
+
app = FastAPI()
|
|
263
|
+
register_health_routes(
|
|
264
|
+
app, liveness=False, readiness_path="/_ready", probes=[Probe("sql", _ok)]
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
with TestClient(app) as client:
|
|
268
|
+
assert client.get("/_ready").status_code == 200
|
|
269
|
+
assert client.get("/health/ready").status_code == 404
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_response_factory_adapts_the_body_but_not_the_status():
|
|
273
|
+
"""
|
|
274
|
+
La otra mitad de la adopción: conservar la forma del cuerpo sin renunciar a que un
|
|
275
|
+
503 siga siendo un 503, que es lo que lee el orquestador.
|
|
276
|
+
"""
|
|
277
|
+
app = FastAPI()
|
|
278
|
+
register_health_routes(
|
|
279
|
+
app,
|
|
280
|
+
probes=[Probe("sql", _boom)],
|
|
281
|
+
response_factory=lambda report: {
|
|
282
|
+
"ok": report.status != "down",
|
|
283
|
+
"checks": [dep.name for dep in report.dependencies],
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
with TestClient(app) as client:
|
|
288
|
+
live = client.get("/health")
|
|
289
|
+
ready = client.get("/health/ready")
|
|
290
|
+
|
|
291
|
+
assert live.status_code == 200
|
|
292
|
+
assert live.json() == {"ok": True, "checks": []}
|
|
293
|
+
|
|
294
|
+
assert ready.status_code == 503
|
|
295
|
+
assert ready.json() == {"ok": False, "checks": ["sql"]}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_response_factory_body_is_not_forced_into_the_health_report_schema():
|
|
299
|
+
"""
|
|
300
|
+
Con `response_model=HealthReport`, FastAPI filtraría el cuerpo del factory a los
|
|
301
|
+
campos del informe y devolvería `{}`: adaptarlo sería inútil.
|
|
302
|
+
"""
|
|
303
|
+
app = FastAPI()
|
|
304
|
+
register_health_routes(app, response_factory=lambda report: {"forma": "propia"})
|
|
305
|
+
|
|
306
|
+
with TestClient(app) as client:
|
|
307
|
+
assert client.get("/health").json() == {"forma": "propia"}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_create_app_can_register_only_the_readiness():
|
|
311
|
+
from hexcore.infrastructure.api.app import AppFeatures, HealthRoutes, create_app
|
|
312
|
+
|
|
313
|
+
app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
|
|
314
|
+
|
|
315
|
+
@app.get("/health")
|
|
316
|
+
async def mi_health() -> dict[str, str]:
|
|
317
|
+
return {"estado": "vivo"}
|
|
318
|
+
|
|
319
|
+
with TestClient(app) as client:
|
|
320
|
+
assert client.get("/health").json() == {"estado": "vivo"}
|
|
321
|
+
assert client.get("/health/ready").status_code in (200, 503)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_create_app_health_true_still_registers_both():
|
|
325
|
+
from hexcore.infrastructure.api.app import create_app
|
|
326
|
+
|
|
327
|
+
app = create_app()
|
|
328
|
+
|
|
329
|
+
paths = {route.path for route in app.routes if hasattr(route, "path")}
|
|
330
|
+
|
|
331
|
+
assert {"/health", "/health/ready"} <= paths
|