hexcore 6.2.0__tar.gz → 7.0.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.2.0/hexcore.egg-info → hexcore-7.0.0}/PKG-INFO +1 -2
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/config.py +87 -3
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/__init__.py +2 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/app.py +8 -2
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/exception_handlers.py +37 -1
- hexcore-7.0.0/hexcore/infrastructure/api/rate_limit.py +310 -0
- hexcore-7.0.0/hexcore/infrastructure/cache/__init__.py +51 -0
- hexcore-7.0.0/hexcore/infrastructure/cache/cache_backends/memory.py +58 -0
- hexcore-7.0.0/hexcore/infrastructure/cache/cache_backends/redis.py +63 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cli.py +13 -1
- {hexcore-6.2.0 → hexcore-7.0.0/hexcore.egg-info}/PKG-INFO +1 -2
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore.egg-info/SOURCES.txt +2 -3
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore.egg-info/requires.txt +0 -1
- hexcore-7.0.0/hexcore.egg-info/top_level.txt +1 -0
- hexcore-7.0.0/pyproject.toml +122 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_exception_handlers.py +86 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_rate_limit.py +128 -3
- hexcore-7.0.0/tests/test_config_cors_security.py +187 -0
- hexcore-7.0.0/tests/test_packaging.py +136 -0
- hexcore-6.2.0/hexcore/infrastructure/api/rate_limit.py +0 -165
- hexcore-6.2.0/hexcore/infrastructure/cache/__init__.py +0 -16
- hexcore-6.2.0/hexcore/infrastructure/cache/cache_backends/memory.py +0 -24
- hexcore-6.2.0/hexcore/infrastructure/cache/cache_backends/redis.py +0 -31
- hexcore-6.2.0/hexcore.egg-info/top_level.txt +0 -5
- hexcore-6.2.0/pyproject.toml +0 -74
- hexcore-6.2.0/scripts/__init__.py +0 -0
- hexcore-6.2.0/scripts/main.py +0 -10
- hexcore-6.2.0/tests/conftest.py +0 -21
- {hexcore-6.2.0 → hexcore-7.0.0}/LICENSE +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/MANIFEST.in +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/README.md +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/__main__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/_deprecation.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/cqrs/scheduler.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/dtos/cursor.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/dtos/errors.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/cqrs.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/base.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/context.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/cron.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/resolution.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/events.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/services.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/domain/uow.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/fastapi.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/cqrs.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/health.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/lifespan.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/middlewares.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/routing.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/streaming.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/cron_sql.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/uow/scopes.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/infrastructure/workers/runner.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/py.typed +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/sql.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/testing/__init__.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/testing/fakes.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/testing/fixtures.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/testing/helpers.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore/types.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/setup.cfg +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_app_and_lifespan.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_cqrs_providers.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_health.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_middlewares_and_routing.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_api_streaming.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_basic.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_celery_event_loop.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_config_loading.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_consumer_optional_event_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_cqrs.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_cqrs_factory.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_cqrs_middlewares.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_cron_sql.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_deprecations.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_documentation_examples.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_dotted_resolution.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_facades.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_handler_registry.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_lock_error_policy.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_optional_dependencies.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_postgres_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_postgres_lock.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_query_cursor_and_endpoint.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_redis_bus.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_redis_lock.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_scheduler.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_scheduler_catchup.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_smart_routing.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_sql_session_layer.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_task_queues_adapters.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_testing_utilities.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_uow_session_regression.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_use_cases_query.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_worker_bus_integration.py +0 -0
- {hexcore-6.2.0 → hexcore-7.0.0}/tests/test_worker_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version:
|
|
3
|
+
Version: 7.0.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
|
|
@@ -10,7 +10,6 @@ License-File: LICENSE
|
|
|
10
10
|
Requires-Dist: croniter>=2.0.0
|
|
11
11
|
Requires-Dist: pydantic>=2.11.7
|
|
12
12
|
Requires-Dist: typer>=0.17.3
|
|
13
|
-
Requires-Dist: ruff>=0.12.11
|
|
14
13
|
Provides-Extra: api
|
|
15
14
|
Requires-Dist: fastapi>=0.116.1; extra == "api"
|
|
16
15
|
Provides-Extra: redis
|
|
@@ -42,9 +42,22 @@ class ServerConfig(BaseModel):
|
|
|
42
42
|
redis_cache_duration: int = 300 # seconds
|
|
43
43
|
|
|
44
44
|
# Security
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
]
|
|
45
|
+
#
|
|
46
|
+
# `allow_origins` NO se puede derivar en el cuerpo de la clase. La versión anterior
|
|
47
|
+
# era ``["*" if debug else "http://localhost:{port}"]``, y ese `debug` es el del
|
|
48
|
+
# cuerpo de clase (siempre `True`), así que el condicional era código muerto y el
|
|
49
|
+
# valor era **siempre** `["*"]` — incluso con `ServerConfig(debug=False)`.
|
|
50
|
+
#
|
|
51
|
+
# Combinado con `allow_credentials=True`, eso es un agujero real y no teórico:
|
|
52
|
+
# Starlette no puede mandar `*` junto con credenciales, así que cuando hay cookie
|
|
53
|
+
# **refleja el Origin del atacante** (`CORSMiddleware`, rama
|
|
54
|
+
# `if self.allow_all_origins and has_cookie`) y agrega
|
|
55
|
+
# `Access-Control-Allow-Credentials: true`. Cualquier origen puede entonces leer
|
|
56
|
+
# respuestas autenticadas con la cookie de sesión de la víctima, sin necesidad de XSS.
|
|
57
|
+
#
|
|
58
|
+
# Se deriva en un validador `mode="after"`, que sí ve el `debug` y el `port` de **la
|
|
59
|
+
# instancia**. Si lo pasás explícito, no se toca.
|
|
60
|
+
allow_origins: list[str] = Field(default_factory=list)
|
|
48
61
|
allow_credentials: bool = True
|
|
49
62
|
allow_methods: list[str] = ["*"]
|
|
50
63
|
allow_headers: list[str] = ["*"]
|
|
@@ -96,6 +109,77 @@ class ServerConfig(BaseModel):
|
|
|
96
109
|
data["event_bus"] = data.pop("event_dispatcher")
|
|
97
110
|
return data
|
|
98
111
|
|
|
112
|
+
# ── CORS ──────────────────────────────────────────────────────────────────
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def _resolve_cors_origins(self) -> "ServerConfig":
|
|
115
|
+
"""
|
|
116
|
+
Deriva `allow_origins` del `debug`/`port` reales y rechaza la combinación insegura.
|
|
117
|
+
|
|
118
|
+
Dos cosas, en este orden:
|
|
119
|
+
|
|
120
|
+
1. Si no pasaste `allow_origins`, se completa: en `debug` queda `["*"]` (comodidad
|
|
121
|
+
de desarrollo, que era la intención original), y fuera de `debug` queda
|
|
122
|
+
`["http://localhost:<port>"]`. Un valor explícito —incluso `[]`— se respeta.
|
|
123
|
+
2. **`"*"` con `allow_credentials=True` nunca es válido, y el invariante vale
|
|
124
|
+
siempre, no sólo fuera de `debug`.** Es que `debug` viene en `True` por
|
|
125
|
+
defecto, así que condicionarlo al entorno dejaba la combinación peligrosa
|
|
126
|
+
como configuración *de fábrica*: `create_app()` sin tocar nada reflejaba el
|
|
127
|
+
Origin del atacante. Según cómo lo pediste:
|
|
128
|
+
|
|
129
|
+
- Si **no** declaraste `allow_credentials`, se baja a `False` y se avisa. Sin
|
|
130
|
+
`Access-Control-Allow-Credentials: true` el navegador no expone la respuesta
|
|
131
|
+
a una petición con cookies, así que el reflejo queda inofensivo.
|
|
132
|
+
- Si declaraste las dos cosas, **no arranca**: pediste explícitamente algo que
|
|
133
|
+
la especificación de CORS no permite, y adivinar cuál de las dos querías
|
|
134
|
+
sería peor que fallar.
|
|
135
|
+
|
|
136
|
+
O sea: si necesitás cookies, declarás tus orígenes. Es la única combinación que
|
|
137
|
+
sirve para sesiones `HttpOnly`, y ahora el default te empuja hacia ella en vez de
|
|
138
|
+
dejarte un agujero abierto.
|
|
139
|
+
"""
|
|
140
|
+
# Se lee ANTES de asignar: en pydantic v2, asignar un campo lo agrega a
|
|
141
|
+
# `model_fields_set`, así que consultarlo después de derivar el default daría
|
|
142
|
+
# "lo pidió el usuario" para un valor que puso el framework.
|
|
143
|
+
lo_pidio_el_usuario = "allow_origins" in self.model_fields_set
|
|
144
|
+
|
|
145
|
+
if not lo_pidio_el_usuario and not self.allow_origins:
|
|
146
|
+
self.allow_origins = (
|
|
147
|
+
["*"] if self.debug else [f"http://localhost:{self.port}"]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if "*" not in self.allow_origins or not self.allow_credentials:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
credenciales_explicitas = "allow_credentials" in self.model_fields_set
|
|
154
|
+
|
|
155
|
+
if credenciales_explicitas and lo_pidio_el_usuario:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"allow_origins=['*'] junto con allow_credentials=True permite que "
|
|
158
|
+
"cualquier origen lea respuestas autenticadas: el navegador no puede "
|
|
159
|
+
"mandar '*' con credenciales, así que Starlette refleja el Origin de "
|
|
160
|
+
"quien pregunte y agrega Access-Control-Allow-Credentials.\n\n"
|
|
161
|
+
"Pediste las dos cosas explícitamente, así que no se adivina. Elegí:\n\n"
|
|
162
|
+
" config.allow_origins = ['https://tu-front.com']\n"
|
|
163
|
+
" # o, si de verdad querés una API pública sin cookies:\n"
|
|
164
|
+
" config.allow_credentials = False\n"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Una de las dos vino por default, así que se baja `allow_credentials` en vez de
|
|
168
|
+
# negarse a arrancar. Es lo que hace que la configuración de fábrica sea segura.
|
|
169
|
+
import warnings
|
|
170
|
+
|
|
171
|
+
self.allow_credentials = False
|
|
172
|
+
warnings.warn(
|
|
173
|
+
"allow_origins incluye '*', así que allow_credentials se baja a False: "
|
|
174
|
+
"'*' con credenciales haría que Starlette reflejara el Origin de cualquiera "
|
|
175
|
+
"que mande una cookie, y el navegador no permite esa combinación de todos "
|
|
176
|
+
"modos. Si necesitás cookies de sesión, declará tus orígenes:\n\n"
|
|
177
|
+
" config.allow_origins = ['http://localhost:3000']\n",
|
|
178
|
+
stacklevel=2,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return self
|
|
182
|
+
|
|
99
183
|
|
|
100
184
|
class LazyConfig:
|
|
101
185
|
"""
|
|
@@ -10,6 +10,7 @@ from .cqrs import (
|
|
|
10
10
|
)
|
|
11
11
|
from .exception_handlers import (
|
|
12
12
|
DEFAULT_EXCEPTION_STATUS_MAP,
|
|
13
|
+
HeadersFactory,
|
|
13
14
|
register_exception_handlers,
|
|
14
15
|
)
|
|
15
16
|
from .health import (
|
|
@@ -48,6 +49,7 @@ __all__ = [
|
|
|
48
49
|
"mount_routers",
|
|
49
50
|
# Handlers de excepción (F5)
|
|
50
51
|
"DEFAULT_EXCEPTION_STATUS_MAP",
|
|
52
|
+
"HeadersFactory",
|
|
51
53
|
"register_exception_handlers",
|
|
52
54
|
# Providers CQRS (F6)
|
|
53
55
|
"CQRSContainer",
|
|
@@ -14,7 +14,7 @@ from fastapi import FastAPI
|
|
|
14
14
|
from fastapi.middleware.cors import CORSMiddleware
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
-
from .exception_handlers import register_exception_handlers
|
|
17
|
+
from .exception_handlers import HeadersFactory, register_exception_handlers
|
|
18
18
|
from .health import Probe, ResponseFactory, register_health_routes
|
|
19
19
|
from .middlewares import RequestIDMiddleware, TimingMiddleware
|
|
20
20
|
from .routing import MountableRouter, mount_routers
|
|
@@ -78,6 +78,7 @@ def create_app(
|
|
|
78
78
|
routers: t.Sequence[MountableRouter] | None = None,
|
|
79
79
|
health_probes: t.Sequence[Probe] | None = None,
|
|
80
80
|
exception_mapping: dict[type[Exception], int] | None = None,
|
|
81
|
+
exception_headers: HeadersFactory | None = None,
|
|
81
82
|
**fastapi_kwargs: t.Any,
|
|
82
83
|
) -> FastAPI:
|
|
83
84
|
"""
|
|
@@ -94,6 +95,9 @@ def create_app(
|
|
|
94
95
|
configuración (F16). Qué rutas se registran y con qué forma se controla con
|
|
95
96
|
`AppFeatures(health=HealthRoutes(...))`.
|
|
96
97
|
exception_mapping: Excepciones extra a mapear, fusionadas con el default (F5).
|
|
98
|
+
exception_headers: Headers extra por excepción. Necesario para los status que
|
|
99
|
+
los exigen por especificación: un 401 tiene que llevar `WWW-Authenticate`
|
|
100
|
+
(RFC 6750 §3).
|
|
97
101
|
**fastapi_kwargs: Se pasan tal cual a `FastAPI` (`title`, `version`,
|
|
98
102
|
`docs_url`, `openapi_tags`…). Lo que pases gana sobre los defaults derivados
|
|
99
103
|
de la configuración.
|
|
@@ -123,7 +127,9 @@ def create_app(
|
|
|
123
127
|
app.add_middleware(RequestIDMiddleware)
|
|
124
128
|
|
|
125
129
|
if resolved_features.exception_handlers:
|
|
126
|
-
register_exception_handlers(
|
|
130
|
+
register_exception_handlers(
|
|
131
|
+
app, mapping=exception_mapping, headers_for=exception_headers
|
|
132
|
+
)
|
|
127
133
|
|
|
128
134
|
if resolved_features.health:
|
|
129
135
|
health_routes = (
|
|
@@ -40,6 +40,7 @@ DEFAULT_EXCEPTION_STATUS_MAP: dict[type[Exception], int] = {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
DetailFactory = t.Callable[[Exception], str]
|
|
43
|
+
HeadersFactory = t.Callable[[Exception], t.Mapping[str, str]]
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def register_exception_handlers(
|
|
@@ -47,6 +48,7 @@ def register_exception_handlers(
|
|
|
47
48
|
*,
|
|
48
49
|
mapping: dict[type[Exception], int] | None = None,
|
|
49
50
|
include_detail: bool | DetailFactory = True,
|
|
51
|
+
headers_for: HeadersFactory | None = None,
|
|
50
52
|
) -> None:
|
|
51
53
|
"""
|
|
52
54
|
Registra handlers que traducen excepciones a respuestas JSON.
|
|
@@ -58,13 +60,31 @@ def register_exception_handlers(
|
|
|
58
60
|
include_detail: Qué poner en el campo ``detail`` de la respuesta.
|
|
59
61
|
``True`` → ``str(exc)``. ``False`` → un mensaje genérico por status, para no
|
|
60
62
|
filtrar internals en una API pública. Un callable → lo que devuelva.
|
|
63
|
+
headers_for: Headers extra por excepción. Recibe la excepción y devuelve un mapa
|
|
64
|
+
de headers; devolvé un mapa vacío para no agregar nada. Hace falta porque hay
|
|
65
|
+
status codes que **exigen** un header por especificación y no se pueden
|
|
66
|
+
expresar sólo con un número: un 401 tiene que llevar `WWW-Authenticate`
|
|
67
|
+
(RFC 6750 §3), y un 429 un `Retry-After`.
|
|
61
68
|
|
|
62
69
|
El cuerpo es siempre ``{"detail": ..., "error": "<NombreDeLaExcepción>"}``, para que
|
|
63
70
|
el cliente pueda distinguir el caso sin parsear el texto.
|
|
64
71
|
|
|
72
|
+
Se eligió un callable aparte antes que cambiar el tipo de valor de
|
|
73
|
+
`DEFAULT_EXCEPTION_STATUS_MAP` a ``int | tuple[int, Mapping]``: el mapa es público y
|
|
74
|
+
hay código que lo lee esperando enteros, así que cambiarlo rompería a quien lo
|
|
75
|
+
inspecciona o lo extiende.
|
|
76
|
+
|
|
65
77
|
Uso::
|
|
66
78
|
|
|
67
79
|
register_exception_handlers(app, mapping={MiNotFound: 404})
|
|
80
|
+
|
|
81
|
+
# 401 con el header que la especificación exige
|
|
82
|
+
def cabeceras(exc: Exception) -> dict[str, str]:
|
|
83
|
+
if isinstance(exc, TokenInvalido):
|
|
84
|
+
return {"WWW-Authenticate": 'Bearer error="invalid_token"'}
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
register_exception_handlers(app, mapping={TokenInvalido: 401}, headers_for=cabeceras)
|
|
68
88
|
"""
|
|
69
89
|
resolved = {**DEFAULT_EXCEPTION_STATUS_MAP, **(mapping or {})}
|
|
70
90
|
|
|
@@ -74,7 +94,7 @@ def register_exception_handlers(
|
|
|
74
94
|
for exc_type in sorted(resolved, key=_specificity, reverse=True):
|
|
75
95
|
app.add_exception_handler(
|
|
76
96
|
exc_type,
|
|
77
|
-
_build_handler(exc_type, resolved[exc_type], include_detail),
|
|
97
|
+
_build_handler(exc_type, resolved[exc_type], include_detail, headers_for),
|
|
78
98
|
)
|
|
79
99
|
|
|
80
100
|
|
|
@@ -87,6 +107,7 @@ def _build_handler(
|
|
|
87
107
|
exc_type: type[Exception],
|
|
88
108
|
status_code: int,
|
|
89
109
|
include_detail: bool | DetailFactory,
|
|
110
|
+
headers_for: HeadersFactory | None = None,
|
|
90
111
|
) -> t.Callable[[Request, Exception], t.Awaitable[JSONResponse]]:
|
|
91
112
|
async def handler(request: Request, exc: Exception) -> JSONResponse:
|
|
92
113
|
if status_code >= 500:
|
|
@@ -107,12 +128,27 @@ def _build_handler(
|
|
|
107
128
|
exc,
|
|
108
129
|
)
|
|
109
130
|
|
|
131
|
+
headers: dict[str, str] | None = None
|
|
132
|
+
if headers_for is not None:
|
|
133
|
+
# Un `headers_for` que explota no puede convertir un 401 prolijo en un 500:
|
|
134
|
+
# el header es accesorio y la respuesta ya está decidida.
|
|
135
|
+
try:
|
|
136
|
+
extra = headers_for(exc)
|
|
137
|
+
except Exception:
|
|
138
|
+
logger.exception(
|
|
139
|
+
"headers_for falló para %s; se responde sin headers extra.",
|
|
140
|
+
type(exc).__name__,
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
headers = {str(k): str(v) for k, v in dict(extra).items()} or None
|
|
144
|
+
|
|
110
145
|
return JSONResponse(
|
|
111
146
|
status_code=status_code,
|
|
112
147
|
content={
|
|
113
148
|
"detail": _detail_for(exc, status_code, include_detail),
|
|
114
149
|
"error": type(exc).__name__,
|
|
115
150
|
},
|
|
151
|
+
headers=headers,
|
|
116
152
|
)
|
|
117
153
|
|
|
118
154
|
handler.__name__ = f"handle_{exc_type.__name__}"
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting como dependencia de FastAPI.
|
|
3
|
+
|
|
4
|
+
Se apoya en el puerto `ICache`, no en Redis directamente, para que funcione con
|
|
5
|
+
`MemoryCache` en los tests sin levantar nada.
|
|
6
|
+
|
|
7
|
+
La ventana lleva su propio vencimiento dentro del valor cacheado (`reset_at`) en vez de
|
|
8
|
+
depender del TTL del backend: `MemoryCache` ignora el parámetro `expire`, y un rate
|
|
9
|
+
limiter que sólo funciona contra Redis no se puede testear.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ipaddress
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
import typing as t
|
|
17
|
+
|
|
18
|
+
from fastapi import HTTPException, Request
|
|
19
|
+
|
|
20
|
+
from hexcore.infrastructure.cache import ICache, SupportsAtomicWindow
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("hexcore.api.rate_limit")
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"rate_limit",
|
|
26
|
+
"RateLimitBackendPolicy",
|
|
27
|
+
"client_ip_key",
|
|
28
|
+
"forwarded_ip_key",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
RateLimitBackendPolicy = t.Literal["allow", "deny"]
|
|
32
|
+
|
|
33
|
+
KeyFunc = t.Callable[[Request], str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def client_ip_key(request: Request) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Clave por IP del par TCP. **No** mira `X-Forwarded-For`.
|
|
39
|
+
|
|
40
|
+
Antes sí lo miraba, sin condiciones, y eso hacía que el límite no limitara nada:
|
|
41
|
+
`X-Forwarded-For` lo escribe el cliente, así que mandar un valor distinto en cada
|
|
42
|
+
petición daba un bucket nuevo en cada petición. Contra un endpoint de login, un
|
|
43
|
+
límite de 5 intentos se volvía infinito con una línea de código.
|
|
44
|
+
|
|
45
|
+
Si estás detrás de un proxy o un balanceador, `request.client.host` es la IP del
|
|
46
|
+
proxy y esto limita a todo el mundo junto — que es el lado **seguro** del error, pero
|
|
47
|
+
no el que querés. Para ese caso usá `forwarded_ip_key()`, que exige declarar en quién
|
|
48
|
+
confiás.
|
|
49
|
+
|
|
50
|
+
Uso::
|
|
51
|
+
|
|
52
|
+
# sin proxy
|
|
53
|
+
rate_limit(5, 300, key=client_ip_key)
|
|
54
|
+
|
|
55
|
+
# detrás de un balanceador
|
|
56
|
+
rate_limit(5, 300, key=forwarded_ip_key(trusted_proxies=["10.0.0.0/8"]))
|
|
57
|
+
"""
|
|
58
|
+
client = request.client
|
|
59
|
+
return client.host if client else "unknown"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def forwarded_ip_key(
|
|
63
|
+
*,
|
|
64
|
+
trusted_proxies: t.Collection[str],
|
|
65
|
+
trust_hops: int = 1,
|
|
66
|
+
) -> KeyFunc:
|
|
67
|
+
"""
|
|
68
|
+
Clave por IP real del cliente, leyendo `X-Forwarded-For` **sólo de proxies declarados**.
|
|
69
|
+
|
|
70
|
+
El header sólo se honra si el par TCP inmediato está en `trusted_proxies`. Si no lo
|
|
71
|
+
está, se cae a la IP del par y se ignora lo que haya mandado: alguien que llega
|
|
72
|
+
directo a la app no puede elegir su propio bucket.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
trusted_proxies: IPs o redes CIDR de tus proxies (``["10.0.0.0/8", "172.18.0.5"]``).
|
|
76
|
+
No puede estar vacío: un allowlist vacío significaría "no confío en nadie", y
|
|
77
|
+
para eso ya está `client_ip_key`.
|
|
78
|
+
trust_hops: Cuántos proxies tenés adelante. Cada proxy **agrega** la IP de quien le
|
|
79
|
+
habló, así que con un balanceador el cliente es el último elemento del header,
|
|
80
|
+
con dos es el penúltimo, y así. Se cuenta desde la derecha, que es la única
|
|
81
|
+
parte del header que no controla el cliente: los elementos de la izquierda los
|
|
82
|
+
puede inventar.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: Si `trusted_proxies` está vacío, si alguna entrada no es una IP/red
|
|
86
|
+
válida, o si `trust_hops < 1`.
|
|
87
|
+
|
|
88
|
+
Uso::
|
|
89
|
+
|
|
90
|
+
# un ALB delante
|
|
91
|
+
key = forwarded_ip_key(trusted_proxies=["10.0.0.0/8"])
|
|
92
|
+
|
|
93
|
+
# CDN -> balanceador -> app
|
|
94
|
+
key = forwarded_ip_key(trusted_proxies=["10.0.0.0/8"], trust_hops=2)
|
|
95
|
+
"""
|
|
96
|
+
if trust_hops < 1:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"forwarded_ip_key(trust_hops=...) tiene que ser >= 1. Si no tenés ningún "
|
|
99
|
+
"proxy adelante, usá `client_ip_key` en vez de esta función."
|
|
100
|
+
)
|
|
101
|
+
if not trusted_proxies:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"forwarded_ip_key(trusted_proxies=...) no puede estar vacío: sin proxies de "
|
|
104
|
+
"confianza, `X-Forwarded-For` lo controla el cliente y el límite no limita "
|
|
105
|
+
"nada. Si no tenés proxy, usá `client_ip_key`."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
109
|
+
for entry in trusted_proxies:
|
|
110
|
+
try:
|
|
111
|
+
networks.append(ipaddress.ip_network(entry, strict=False))
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"forwarded_ip_key(trusted_proxies=...): {entry!r} no es una IP ni una red "
|
|
115
|
+
f"CIDR válida. Ejemplos válidos: '10.0.0.0/8', '172.18.0.5'."
|
|
116
|
+
) from exc
|
|
117
|
+
|
|
118
|
+
def key_func(request: Request) -> str:
|
|
119
|
+
client = request.client
|
|
120
|
+
peer = client.host if client else None
|
|
121
|
+
if peer is None or not _ip_in_any(peer, networks):
|
|
122
|
+
# El par no es un proxy declarado: su header no vale nada.
|
|
123
|
+
return peer or "unknown"
|
|
124
|
+
|
|
125
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
126
|
+
if not forwarded:
|
|
127
|
+
return peer
|
|
128
|
+
|
|
129
|
+
hops = [item.strip() for item in forwarded.split(",") if item.strip()]
|
|
130
|
+
if len(hops) < trust_hops:
|
|
131
|
+
# Menos saltos de los declarados: el header está incompleto o alguien lo
|
|
132
|
+
# recortó. Se cae al par en vez de agarrar la entrada más a la izquierda,
|
|
133
|
+
# que es justo la que el cliente puede inventar.
|
|
134
|
+
return peer
|
|
135
|
+
|
|
136
|
+
candidate = hops[-trust_hops]
|
|
137
|
+
try:
|
|
138
|
+
ipaddress.ip_address(candidate)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return peer
|
|
141
|
+
return candidate
|
|
142
|
+
|
|
143
|
+
return key_func
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ip_in_any(
|
|
147
|
+
address: str,
|
|
148
|
+
networks: t.Sequence[ipaddress.IPv4Network | ipaddress.IPv6Network],
|
|
149
|
+
) -> bool:
|
|
150
|
+
try:
|
|
151
|
+
parsed = ipaddress.ip_address(address)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return False
|
|
154
|
+
return any(parsed in network for network in networks)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def rate_limit(
|
|
158
|
+
limit: int,
|
|
159
|
+
window_seconds: int = 60,
|
|
160
|
+
*,
|
|
161
|
+
key: KeyFunc | None = None,
|
|
162
|
+
cache: ICache | None = None,
|
|
163
|
+
on_backend_error: RateLimitBackendPolicy = "allow",
|
|
164
|
+
namespace: str = "hexcore:ratelimit",
|
|
165
|
+
) -> t.Callable[..., t.Awaitable[None]]:
|
|
166
|
+
"""
|
|
167
|
+
Construye una dependencia que limita las peticiones por clave.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
limit: Peticiones permitidas por ventana.
|
|
171
|
+
window_seconds: Duración de la ventana.
|
|
172
|
+
key: Cómo derivar la clave del request. Por defecto, la IP del cliente. Para
|
|
173
|
+
limitar por usuario: ``key=lambda r: r.state.user_id``.
|
|
174
|
+
cache: El backend. Por defecto, `config.cache_backend`.
|
|
175
|
+
on_backend_error: Qué hacer si el backend falla. ``"allow"`` (default) deja pasar
|
|
176
|
+
—un Redis caído no debería tumbar la API—; ``"deny"`` devuelve 429. Es una
|
|
177
|
+
decisión explícita y configurable, no enterrada en un `except`.
|
|
178
|
+
namespace: Prefijo de las claves en el cache.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Una corutina para usar en ``Depends(...)``.
|
|
182
|
+
|
|
183
|
+
Devuelve **429 con `Retry-After`**, que es lo que un cliente bien hecho necesita para
|
|
184
|
+
reintentar sin adivinar.
|
|
185
|
+
|
|
186
|
+
Uso::
|
|
187
|
+
|
|
188
|
+
@router.get("/reports", dependencies=[Depends(rate_limit(10, 60))])
|
|
189
|
+
async def reports(): ...
|
|
190
|
+
|
|
191
|
+
por_usuario = rate_limit(100, 3600, key=lambda r: r.state.user_id)
|
|
192
|
+
"""
|
|
193
|
+
if limit < 1:
|
|
194
|
+
raise ValueError("rate_limit(limit=...) tiene que ser >= 1")
|
|
195
|
+
if window_seconds < 1:
|
|
196
|
+
raise ValueError("rate_limit(window_seconds=...) tiene que ser >= 1")
|
|
197
|
+
|
|
198
|
+
key_func = key or client_ip_key
|
|
199
|
+
|
|
200
|
+
async def dependency(request: Request) -> None:
|
|
201
|
+
backend = cache or _default_cache()
|
|
202
|
+
cache_key = f"{namespace}:{key_func(request)}"
|
|
203
|
+
now = time.time()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
if isinstance(backend, SupportsAtomicWindow):
|
|
207
|
+
# Camino atómico: se incrementa primero y se compara después. Al revés
|
|
208
|
+
# —leer, comparar, escribir— hay un `await` entre la lectura y la
|
|
209
|
+
# escritura, así que N peticiones concurrentes leen el mismo contador y
|
|
210
|
+
# pasan todas.
|
|
211
|
+
count, reset_at = await backend.incr_window(cache_key, window_seconds)
|
|
212
|
+
excedido = count > limit
|
|
213
|
+
else:
|
|
214
|
+
_warn_backend_sin_atomicidad(backend)
|
|
215
|
+
state = await backend.get(cache_key)
|
|
216
|
+
count, reset_at = _read_window(state, now, window_seconds)
|
|
217
|
+
excedido = count >= limit
|
|
218
|
+
if not excedido:
|
|
219
|
+
result = backend.set(
|
|
220
|
+
cache_key,
|
|
221
|
+
{"count": count + 1, "reset_at": reset_at},
|
|
222
|
+
expire=window_seconds,
|
|
223
|
+
)
|
|
224
|
+
if _is_awaitable(result):
|
|
225
|
+
await result
|
|
226
|
+
|
|
227
|
+
if excedido:
|
|
228
|
+
retry_after = max(1, int(reset_at - now) + 1)
|
|
229
|
+
raise HTTPException(
|
|
230
|
+
status_code=429,
|
|
231
|
+
detail=(
|
|
232
|
+
f"Límite de {limit} peticiones cada {window_seconds}s alcanzado."
|
|
233
|
+
),
|
|
234
|
+
headers={"Retry-After": str(retry_after)},
|
|
235
|
+
)
|
|
236
|
+
except HTTPException:
|
|
237
|
+
raise
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
if on_backend_error == "deny":
|
|
240
|
+
logger.critical(
|
|
241
|
+
"El backend de rate limiting falló (%s) y on_backend_error='deny': "
|
|
242
|
+
"se rechaza la petición.",
|
|
243
|
+
exc,
|
|
244
|
+
)
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=429,
|
|
247
|
+
detail="Rate limiting no disponible.",
|
|
248
|
+
headers={"Retry-After": str(window_seconds)},
|
|
249
|
+
) from exc
|
|
250
|
+
logger.error(
|
|
251
|
+
"El backend de rate limiting falló (%s) y on_backend_error='allow': "
|
|
252
|
+
"se deja pasar la petición sin limitar.",
|
|
253
|
+
exc,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return dependency
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _read_window(
|
|
260
|
+
state: t.Any,
|
|
261
|
+
now: float,
|
|
262
|
+
window_seconds: int,
|
|
263
|
+
) -> tuple[int, float]:
|
|
264
|
+
"""
|
|
265
|
+
Lee el contador de la ventana en curso.
|
|
266
|
+
|
|
267
|
+
Si no hay estado, o si la ventana ya venció, empieza una nueva. El vencimiento se
|
|
268
|
+
evalúa aquí y no se delega al TTL del backend, porque no todos los backends de
|
|
269
|
+
`ICache` lo respetan.
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(state, dict):
|
|
272
|
+
reset_at = state.get("reset_at")
|
|
273
|
+
count = state.get("count")
|
|
274
|
+
if isinstance(reset_at, (int, float)) and isinstance(count, int):
|
|
275
|
+
if reset_at > now:
|
|
276
|
+
return count, float(reset_at)
|
|
277
|
+
|
|
278
|
+
return 0, now + window_seconds
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _is_awaitable(value: t.Any) -> bool:
|
|
282
|
+
return hasattr(value, "__await__")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_BACKENDS_AVISADOS: set[int] = set()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _warn_backend_sin_atomicidad(backend: ICache) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Avisa una sola vez por backend que el conteo no es atómico.
|
|
291
|
+
|
|
292
|
+
Una vez y no por petición: un log por request en el camino caliente es peor que el
|
|
293
|
+
problema que denuncia. `id()` alcanza porque el backend es un singleton de proceso.
|
|
294
|
+
"""
|
|
295
|
+
marca = id(backend)
|
|
296
|
+
if marca in _BACKENDS_AVISADOS:
|
|
297
|
+
return
|
|
298
|
+
_BACKENDS_AVISADOS.add(marca)
|
|
299
|
+
logger.warning(
|
|
300
|
+
"%s no implementa `incr_window`, así que el conteo del rate limit hace "
|
|
301
|
+
"read-modify-write y un pico de peticiones concurrentes puede pasarse del "
|
|
302
|
+
"límite. Implementá `SupportsAtomicWindow` en tu backend, o usá `RedisCache`.",
|
|
303
|
+
type(backend).__name__,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _default_cache() -> ICache:
|
|
308
|
+
from hexcore.config import LazyConfig
|
|
309
|
+
|
|
310
|
+
return LazyConfig.get_config().cache_backend
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
import abc
|
|
3
|
+
|
|
4
|
+
__all__ = ["ICache", "SupportsAtomicWindow"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ICache(abc.ABC):
|
|
8
|
+
@abc.abstractmethod
|
|
9
|
+
async def get(self, key: str) -> t.Optional[t.Any]:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
@abc.abstractmethod
|
|
13
|
+
def set(self, key: str, value: t.Any, expire: int = 3600) -> t.Any:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
def delete(self, key: str) -> t.Union[t.Any, None]:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@t.runtime_checkable
|
|
22
|
+
class SupportsAtomicWindow(t.Protocol):
|
|
23
|
+
"""
|
|
24
|
+
Capacidad opcional de un backend de cache: contar una ventana **atómicamente**.
|
|
25
|
+
|
|
26
|
+
Existe como Protocol aparte y no como método de `ICache` porque agregar un
|
|
27
|
+
`@abc.abstractmethod` al puerto rompería a todo el que ya tenga un backend propio.
|
|
28
|
+
Quien la implementa, la ofrece; quien no, sigue funcionando.
|
|
29
|
+
|
|
30
|
+
Por qué hace falta: un contador hecho con `get()` y después `set()` tiene un
|
|
31
|
+
read-modify-write con un `await` en el medio, así que N peticiones concurrentes leen
|
|
32
|
+
todas el mismo valor y pasan todas. Contra un endpoint de login eso convierte el
|
|
33
|
+
límite en un techo blando: con `limit=5` y 50 peticiones simultáneas pasan bastante
|
|
34
|
+
más de 5. `rate_limit` usa esta capacidad si el backend la tiene y avisa por log
|
|
35
|
+
cuando no.
|
|
36
|
+
|
|
37
|
+
Uso::
|
|
38
|
+
|
|
39
|
+
class MiCache(ICache):
|
|
40
|
+
async def incr_window(self, key: str, window_seconds: int) -> tuple[int, float]:
|
|
41
|
+
... # devolvé (contador_ya_incrementado, timestamp_de_reset)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
async def incr_window(self, key: str, window_seconds: int) -> tuple[int, float]:
|
|
45
|
+
"""
|
|
46
|
+
Incrementa el contador de `key` y devuelve `(contador, reset_at)`.
|
|
47
|
+
|
|
48
|
+
`reset_at` es un timestamp absoluto (epoch en segundos). La primera llamada de una
|
|
49
|
+
ventana devuelve `1`, y es la que fija el vencimiento.
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import typing as t
|
|
3
|
+
from hexcore.infrastructure.cache import ICache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MemoryCache(ICache):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.cache: t.Dict[str, t.Dict[str, t.Any]] = {}
|
|
9
|
+
|
|
10
|
+
async def incr_window(self, key: str, window_seconds: int) -> tuple[int, float]:
|
|
11
|
+
"""
|
|
12
|
+
Contador de ventana atómico (`SupportsAtomicWindow`).
|
|
13
|
+
|
|
14
|
+
Atómico porque entre la lectura y la escritura **no hay ningún `await`**: dentro
|
|
15
|
+
de un event loop, esta corutina no cede el control en el medio, así que dos
|
|
16
|
+
peticiones concurrentes no pueden leer el mismo contador. Es exactamente la
|
|
17
|
+
garantía que el par `get()`/`set()` no puede dar.
|
|
18
|
+
|
|
19
|
+
No sirve entre procesos, y no pretende: para varios workers hace falta un backend
|
|
20
|
+
compartido (`RedisCache`).
|
|
21
|
+
|
|
22
|
+
El vencimiento se guarda **dentro del valor** porque `set()` acá ignora `expire` y
|
|
23
|
+
nunca desaloja; si el TTL fuera del backend, la ventana no vencería jamás.
|
|
24
|
+
"""
|
|
25
|
+
now = time.time()
|
|
26
|
+
state = self.cache.get(key)
|
|
27
|
+
|
|
28
|
+
reset_at = None
|
|
29
|
+
if isinstance(state, dict):
|
|
30
|
+
candidate = state.get("reset_at")
|
|
31
|
+
if isinstance(candidate, (int, float)) and candidate > now:
|
|
32
|
+
reset_at = float(candidate)
|
|
33
|
+
|
|
34
|
+
if reset_at is None:
|
|
35
|
+
count, reset_at = 1, now + window_seconds
|
|
36
|
+
else:
|
|
37
|
+
previous = state.get("count") if isinstance(state, dict) else 0
|
|
38
|
+
count = (previous if isinstance(previous, int) else 0) + 1
|
|
39
|
+
|
|
40
|
+
self.cache[key] = {"count": count, "reset_at": reset_at}
|
|
41
|
+
return count, reset_at
|
|
42
|
+
|
|
43
|
+
async def get(self, key: str) -> t.Optional[t.Dict[str, t.Any]]:
|
|
44
|
+
return self.cache.get(key)
|
|
45
|
+
|
|
46
|
+
async def set(
|
|
47
|
+
self,
|
|
48
|
+
key: str,
|
|
49
|
+
value: t.Dict[str, t.Any],
|
|
50
|
+
expire: int = 0,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.cache[key] = value
|
|
53
|
+
|
|
54
|
+
async def delete(self, key: str) -> None:
|
|
55
|
+
self.cache.pop(key, None)
|
|
56
|
+
|
|
57
|
+
async def clear(self) -> None:
|
|
58
|
+
self.cache.clear()
|