diff --git a/README.md b/README.md
index 9761bb3..540ce16 100644
--- a/README.md
+++ b/README.md
@@ -255,6 +255,16 @@ For OpenAI-compatible providers, a configured provider `api_key` takes precedenc
- For dev checkouts, `BLOB_STORAGE_PATH=./blob_storage` keeps blobs next to the repo as before.
- `docker-compose.yml` mounts `./logs:/app/logs`, so rotated ERROR logs persist across container restarts.
+### Project request rate limits
+
+SmolRouter applies an atomic, Redis-backed two-window request limit to the five inference POST routes backed by the shared proxy: `/v1/chat/completions`, `/v1/completions`, `/v1/responses`, `/v1/embeddings`, and `/v1/images/generations`. Image edits and variations, model listing, Ollama compatibility routes, and dashboard/API routes are outside this limiter.
+
+Configure `request_rate_limits` in the routes config. The anonymous and project defaults are each 1 request per second and 3 requests per 10 seconds when omitted. Anonymous requests share one global Redis bucket across source IPs, processes, and instances. Identified projects receive isolated buckets. A project's `rate_limit` is a complete replacement for `project_default`; it must specify both windows rather than partially merging them. Set `request_rate_limits.enabled: false` to skip enforcement.
+
+Requests with invalid or mismatched local keys receive `401` before consuming a bucket. An admitted request consumes capacity before its JSON body is parsed or sent upstream. A rejected request receives `429` with OpenAI-style error code `anonymous_rate_limit_exceeded` or `project_rate_limit_exceeded`, a whole-second `Retry-After`, and policy/bucket-class headers that contain no project key or identity. Redis failures fail closed with `503` and code `rate_limiter_unavailable`; production startup also verifies the limiter and refuses FakeRedis when limiting is enabled.
+
+The Projects UI and API expose each configured project's effective two-window policy and whether it comes from the project default or a whole-project override. They do not expose live Redis counters. Request graphs, filters, and drilldowns only cover logs still present in the configured request-log retention window, so they are not authoritative rate-limit accounting.
+
## Testing
SmolRouter ships with an automated pytest suite covering model remapping, routing, logging, and quota enforcement:
diff --git a/config/routes.yaml.example b/config/routes.yaml.example
index f4d83cc..da69451 100644
--- a/config/routes.yaml.example
+++ b/config/routes.yaml.example
@@ -2,6 +2,36 @@
# This file defines routing rules to intelligently direct requests
# to different upstream servers based on source host and model.
+# Request limits use two complete windows. Unidentified callers share the
+# anonymous policy globally; identified projects use project_default unless
+# their facade-key entry supplies a complete rate_limit override.
+request_rate_limits:
+ enabled: true
+ anonymous:
+ windows:
+ - requests: 1
+ seconds: 1
+ - requests: 3
+ seconds: 10
+ project_default:
+ windows:
+ - requests: 1
+ seconds: 1
+ - requests: 3
+ seconds: 10
+
+# Project metadata is safe to keep here; facade-key secrets are stored separately.
+facade_keys:
+ project-a:
+ display_name: "Project A"
+ tags: ["team:ml", "env:dev"]
+ rate_limit:
+ windows:
+ - requests: 5
+ seconds: 1
+ - requests: 30
+ seconds: 10
+
routes:
# Route fast/lightweight models to GPU server
- match:
@@ -41,4 +71,4 @@ routes:
# - source_host matches the client IP address
# - model patterns support exact match or regex (surrounded by /)
# - Both match criteria are optional - you can match on either or both
-# - model override in route is optional - if not specified, original model is preserved
\ No newline at end of file
+# - model override in route is optional - if not specified, original model is preserved
diff --git a/pyproject.toml b/pyproject.toml
index 3998832..e82169d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,7 @@ dependencies = [
"pytz>=2025.2",
"aiohttp>=3.12.15",
"redis>=5.0.0",
- "fakeredis>=2.31.3",
+ "fakeredis[lua]>=2.31.3",
]
[project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
index b12c3e6..4842ad6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,7 +11,7 @@ slowapi
PyJWT
redis
aiohttp>=3.12.15
-fakeredis>=2.31.3
+fakeredis[lua]>=2.31.3
google-genai
google-api-core>=2.19.0
pytz>=2025.2
diff --git a/smolrouter/app.py b/smolrouter/app.py
index 1d0d509..4941b54 100644
--- a/smolrouter/app.py
+++ b/smolrouter/app.py
@@ -48,6 +48,15 @@
from smolrouter.container import initialize_container
from smolrouter.config_paths import normalize_provider_file_references, resolve_routes_config_path
from smolrouter.facade_keys import RequestIdentity
+from smolrouter.redis_config import is_fake_redis
+from smolrouter.request_rate_limits import (
+ RateLimitDecision,
+ RedisRequestRateLimiter,
+ RequestRateLimitConfig,
+ RequestRateLimitConfigError,
+ RequestRateLimitPolicy,
+ parse_request_rate_limit_config,
+)
from smolrouter.request_metadata import REQUEST_LOG_METADATA_FIELDS, apply_request_metadata, serialize_request_metadata
from smolrouter.task_utils import create_logged_task, drain_background_tasks
@@ -110,6 +119,10 @@ def configure_logging() -> None:
# logging handlers/levels are installed before uvicorn configures its own logging.
logger = logging.getLogger("model-rerouter")
+# A single process-wide limiter deliberately shares Redis buckets with every
+# worker and instance. Tests patch this seam when exercising unrelated routes.
+REQUEST_RATE_LIMITER = RedisRequestRateLimiter()
+
FACADE_KEY_HEADER = "X-SmolRouter-Key"
FACADE_KEY_PREFIX = "srk-"
@@ -206,9 +219,9 @@ async def app_lifespan(app: FastAPI):
try:
await _initialize_request_logging_system()
_initialize_blob_storage_strict()
-
# Initialize new architecture container
await init_new_architecture()
+ await _verify_request_rate_limiter_startup()
except Exception:
await _shutdown_proxy_health_monitors(container)
_stop_logging_cleanup_if_enabled()
@@ -575,6 +588,11 @@ async def init_new_architecture():
f"Failed to start proxy health monitor for {provider.get_provider_id()}: {provider_error}"
)
logger.info("New SmolRouter architecture initialized successfully")
+ except RequestRateLimitConfigError:
+ # Request admission cannot safely fall back: malformed limiter policy
+ # must prevent startup instead of silently disabling enforcement.
+ container = None
+ raise
except Exception as e:
logger.exception(f"Failed to initialize new architecture: {e}")
logger.warning("Falling back to legacy architecture only")
@@ -780,6 +798,28 @@ class ProxyRequestContext:
identity: Optional[RequestIdentity] = None
+def _default_request_rate_limit_config() -> RequestRateLimitConfig:
+ return RequestRateLimitConfig()
+
+
+def _request_rate_limit_config(active_container: Any = None) -> RequestRateLimitConfig:
+ if active_container is None:
+ return parse_request_rate_limit_config(ROUTES_CONFIG_DATA.get("request_rate_limits"))
+ config = active_container.get_request_rate_limit_config()
+ return config if isinstance(config, RequestRateLimitConfig) else _default_request_rate_limit_config()
+
+
+async def _verify_request_rate_limiter_startup() -> None:
+ """Fail startup when enabled request limiting cannot be enforced safely."""
+
+ config = _request_rate_limit_config(container)
+ if not config.enabled:
+ return
+ if os.getenv("APP_ENV", "dev").lower() in ("prod", "production") and is_fake_redis():
+ raise RuntimeError("FakeRedis cannot enforce request rate limits in production")
+ await REQUEST_RATE_LIMITER.verify()
+
+
def _extract_model_from_provider_url(model_name: str) -> str:
"""Extract the actual model name from provider URL format"""
# For gemini-* and claude-* models, use as-is
@@ -1404,7 +1444,13 @@ def _build_request_client_context(
)
-def _build_project_inventory_entry(project_id: str, config: Any = None, secret_count: int = 0) -> dict[str, Any]:
+def _build_project_inventory_entry(
+ project_id: str,
+ config: Any = None,
+ secret_count: int = 0,
+ effective_rate_limit: Any = None,
+ rate_limits_enabled: Optional[bool] = None,
+) -> dict[str, Any]:
if config is None:
display_name = None
enabled = False
@@ -1419,6 +1465,11 @@ def _build_project_inventory_entry(project_id: str, config: Any = None, secret_c
quota_policy = getattr(config, "quota", None)
quota_payload = quota_policy.to_dict() if quota_policy is not None else None
+ rate_limit_payload = effective_rate_limit.to_dict() if effective_rate_limit is not None else None
+ rate_limit_source = None
+ if config is not None and effective_rate_limit is not None:
+ rate_limit_source = "project_override" if getattr(config, "rate_limit", None) is not None else "project_default"
+
return {
"id": project_id,
"identity": {
@@ -1433,6 +1484,11 @@ def _build_project_inventory_entry(project_id: str, config: Any = None, secret_c
"default_class": default_class,
"tags": list(tags),
"quota": quota_payload,
+ "request_rate_limit": {
+ "enabled": rate_limits_enabled,
+ "source": rate_limit_source,
+ "policy": rate_limit_payload,
+ },
"configured": config is not None,
}
@@ -1443,11 +1499,14 @@ async def _get_facade_key_registry_data() -> dict[str, Any]:
return {}
registry = active_container.get_facade_key_registry()
+ rate_limit_config = _request_rate_limit_config(active_container)
return {
key_id: {
"config": config,
"secret_count": len(registry.get_secrets(key_id)),
+ "effective_rate_limit": config.rate_limit or rate_limit_config.project_default,
+ "rate_limits_enabled": rate_limit_config.enabled,
}
for key_id, config in registry.configs.items()
}
@@ -1463,6 +1522,8 @@ async def _load_facade_key_inventory() -> dict[str, dict[str, Any]]:
project_id=project_id,
config=entry["config"],
secret_count=entry["secret_count"],
+ effective_rate_limit=entry["effective_rate_limit"],
+ rate_limits_enabled=entry["rate_limits_enabled"],
)
for project_id, entry in sorted(registry_data.items(), key=lambda item: item[0])
}
@@ -1701,6 +1762,17 @@ async def _prepare_proxy_request_context(
auth_payload,
"nonlocal_bearer_token" if presented_bearer_token else "missing_local_facade_key",
)
+ if response is None:
+ # Lifespan initializes the global container even when request
+ # routing is explicitly kept on the legacy path. Reuse only its
+ # validated admission configuration here; do not route through it.
+ context.active_container = container if legacy_proxy else await _get_active_container(False)
+ if context.active_container is not None:
+ context.client_context = _build_request_client_context(
+ context.active_container,
+ source_ip,
+ context.headers,
+ )
return response, context
if legacy_proxy:
@@ -1754,6 +1826,167 @@ async def _prepare_proxy_request_context(
return None, context
+def _rate_limit_response_payload(status_code: int, bucket_class: str) -> Dict[str, Any]:
+ if status_code == 429:
+ return {
+ "error": {
+ "message": (
+ "The request rate limit for this project was exceeded."
+ if bucket_class == "project"
+ else "The anonymous request rate limit was exceeded."
+ ),
+ "type": "rate_limit_error",
+ "param": None,
+ "code": _rate_limit_error_code(bucket_class),
+ }
+ }
+ return {
+ "error": {
+ "message": "Request rate limiting is temporarily unavailable.",
+ "type": "service_unavailable_error",
+ "param": None,
+ "code": "rate_limiter_unavailable",
+ }
+ }
+
+
+def _rate_limit_error_code(bucket_class: str) -> str:
+ return "project_rate_limit_exceeded" if bucket_class == "project" else "anonymous_rate_limit_exceeded"
+
+
+def _rate_limit_headers(decision: RateLimitDecision) -> Dict[str, str]:
+ windows = ", ".join(
+ f"{window.requests};w={window.seconds}" for window in decision.policy.windows
+ )
+ headers = {
+ "X-SmolRouter-RateLimit-Bucket": decision.bucket_class,
+ "X-SmolRouter-RateLimit-Policy": windows,
+ }
+ if not decision.allowed:
+ headers["Retry-After"] = str(decision.retry_after_seconds)
+ return headers
+
+
+def _rate_limit_identity_log_suffix(identity: Optional[RequestIdentity]) -> tuple[str, tuple[str, ...]]:
+ if identity is None:
+ return "", ()
+ return " identity_kind=%s identity_subject_id=%s", (identity.kind, identity.subject_id)
+
+
+async def _log_rate_limit_outcome(
+ request: Request,
+ start_time: float,
+ auth_payload: Optional[Dict[str, Any]],
+ identity: Optional[RequestIdentity],
+ status_code: int,
+ error_code: str,
+) -> None:
+ """Best-effort terminal request logging without reading or retaining a body."""
+
+ try:
+ log_entry = await start_request_log(
+ request,
+ "openai",
+ "rate-limiter",
+ None,
+ None,
+ auth_payload,
+ None,
+ identity,
+ )
+ complete_request_log(
+ log_entry,
+ start_time,
+ {"status_code": status_code, "error_message": error_code},
+ )
+ except Exception:
+ logger.warning("Failed to persist request rate-limit outcome")
+
+
+async def _apply_request_rate_limit(
+ request: Request,
+ start_time: float,
+ auth_payload: Optional[Dict[str, Any]],
+ context: ProxyRequestContext,
+) -> Optional[JSONResponse]:
+ config = _request_rate_limit_config(context.active_container)
+ bucket_class = "project" if context.identity else "anonymous"
+ identity_suffix, identity_args = _rate_limit_identity_log_suffix(context.identity)
+ if not config.enabled:
+ logger.info(
+ "request_rate_limit outcome=skipped status=disabled status_code=none bucket_class=%s "
+ "effective_policy=disabled violated_windows=[] retry_after_ms=0" + identity_suffix,
+ bucket_class,
+ *identity_args,
+ )
+ return None
+
+ policy = context.identity.rate_limit_policy or config.project_default if context.identity else config.anonymous
+ if context.active_container is not None:
+ effective_policy = context.active_container.get_effective_request_rate_limit_policy(context.identity)
+ if isinstance(effective_policy, RequestRateLimitPolicy):
+ policy = effective_policy
+ identity_kwargs: Dict[str, str] = {}
+ if context.identity is not None:
+ identity_kwargs = {
+ "identity_kind": context.identity.kind,
+ "identity_subject_id": context.identity.subject_id,
+ }
+
+ try:
+ decision = await REQUEST_RATE_LIMITER.check(
+ bucket_class,
+ policy,
+ **identity_kwargs,
+ )
+ except Exception:
+ logger.error(
+ "request_rate_limit outcome=unavailable status=error status_code=503 bucket_class=%s "
+ "effective_policy=%s violated_windows=unknown retry_after_ms=unknown" + identity_suffix,
+ bucket_class,
+ policy.to_dict(),
+ *identity_args,
+ )
+ await _log_rate_limit_outcome(
+ request,
+ start_time,
+ auth_payload,
+ context.identity,
+ 503,
+ "rate_limiter_unavailable",
+ )
+ return JSONResponse(content=_rate_limit_response_payload(503, bucket_class), status_code=503)
+
+ logger.info(
+ "request_rate_limit outcome=%s status=%s status_code=%s bucket_class=%s effective_policy=%s "
+ "violated_windows=%s retry_after_ms=%d" + identity_suffix,
+ "admitted" if decision.allowed else "rejected",
+ "allowed" if decision.allowed else "denied",
+ "pending" if decision.allowed else 429,
+ decision.bucket_class,
+ decision.policy.to_dict(),
+ [window.to_dict() for window in decision.violated_windows],
+ decision.retry_after_ms,
+ *identity_args,
+ )
+ if decision.allowed:
+ return None
+
+ await _log_rate_limit_outcome(
+ request,
+ start_time,
+ auth_payload,
+ context.identity,
+ 429,
+ _rate_limit_error_code(bucket_class),
+ )
+ return JSONResponse(
+ content=_rate_limit_response_payload(429, bucket_class),
+ status_code=429,
+ headers=_rate_limit_headers(decision),
+ )
+
+
async def _execute_openai_route_with_logging(
*,
source_ip: str,
@@ -1863,6 +2096,15 @@ async def proxy_request(path: str, request: Request):
if early_response is not None:
return early_response
+ rate_limit_response = await _apply_request_rate_limit(
+ request,
+ start_time,
+ auth_payload,
+ context,
+ )
+ if rate_limit_response is not None:
+ return rate_limit_response
+
payload, request_body_bytes, error_response = await _parse_openai_request_payload(
request, start_time, auth_payload, context.identity
)
@@ -2261,7 +2503,8 @@ async def proxy_ollama_request(path: str, request: Request) -> JSONResponse | St
OPENAI_PROXY_ROUTE_RESPONSES = {
400: {"description": "Invalid request payload"},
401: {"description": "Invalid or mismatched local facade key"},
- 503: {"description": "Routing unavailable"},
+ 429: {"description": "Project or anonymous request rate limit exceeded"},
+ 503: {"description": "Routing or request rate limiter unavailable"},
}
IMAGE_GENERATION_PROXY_ROUTE_RESPONSES = OPENAI_PROXY_ROUTE_RESPONSES
diff --git a/smolrouter/container.py b/smolrouter/container.py
index 229f015..0f6605e 100644
--- a/smolrouter/container.py
+++ b/smolrouter/container.py
@@ -12,11 +12,16 @@
import asyncio
import time
from typing import Dict, Any, List, Optional
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from fastapi.responses import StreamingResponse, JSONResponse
from .interfaces import ClientContext
from .facade_keys import FacadeKeyRegistry, RequestIdentity, load_facade_key_registry
+from .request_rate_limits import (
+ RequestRateLimitConfig,
+ RequestRateLimitPolicy,
+ parse_request_rate_limit_config,
+)
from .providers import ProviderFactory, IModelProvider
from .mediator import ModelMediatorFactory, ModelMediator
from .caching import InMemoryModelCache, NoOpModelCache, IModelCache
@@ -62,6 +67,8 @@ class SmolRouterConfig:
aliases: Optional[Dict[str, Any]] = None
facade_keys: Optional[Dict[str, Any]] = None
facade_key_registry: Optional[FacadeKeyRegistry] = None
+ request_rate_limits: Optional[Dict[str, Any]] = None
+ request_rate_limit_config: RequestRateLimitConfig = field(init=False)
def __post_init__(self):
if self.model_map is None:
@@ -78,6 +85,9 @@ def __post_init__(self):
self.aliases = {}
if self.facade_keys is None:
self.facade_keys = {}
+ if self.request_rate_limits is None:
+ self.request_rate_limits = {}
+ self.request_rate_limit_config = parse_request_rate_limit_config(self.request_rate_limits)
class SmolRouterContainer:
@@ -141,6 +151,7 @@ def _create_default_config(self) -> SmolRouterConfig:
servers=routes_data.get("servers", {}),
aliases=routes_data.get("aliases", {}),
facade_keys=routes_data.get("facade_keys", {}),
+ request_rate_limits=routes_data.get("request_rate_limits", {}),
enable_background_health_checks=enable_background_health_checks,
health_check_interval=health_check_interval,
)
@@ -413,6 +424,20 @@ def create_client_context(
def get_facade_key_registry(self) -> FacadeKeyRegistry:
return self.config.facade_key_registry or load_facade_key_registry(self.config.facade_keys)
+ def get_request_rate_limit_config(self) -> RequestRateLimitConfig:
+ """Return the validated global request-rate-limit configuration."""
+ return self.config.request_rate_limit_config
+
+ def get_effective_request_rate_limit_policy(
+ self,
+ identity: Optional[RequestIdentity],
+ ) -> RequestRateLimitPolicy:
+ """Resolve anonymous, project-default, or project-specific request limits."""
+ config = self.get_request_rate_limit_config()
+ if identity is None:
+ return config.anonymous
+ return identity.rate_limit_policy or config.project_default
+
async def route_request(
self,
source_ip: str,
diff --git a/smolrouter/facade_keys.py b/smolrouter/facade_keys.py
index e134090..3ed6e77 100644
--- a/smolrouter/facade_keys.py
+++ b/smolrouter/facade_keys.py
@@ -10,6 +10,7 @@
from typing import Any, Dict, Iterable, Mapping, Optional
from .facade_key_store import load_facade_key_secrets
+from .request_rate_limits import RequestRateLimitPolicy, parse_request_rate_limit_policy
DEFAULT_FACADE_KEY_ACTION = "observe"
@@ -41,6 +42,7 @@ class FacadeKeyConfig:
tags: tuple[str, ...] = ()
default_class: Optional[str] = None
quota: FacadeQuotaPolicy = field(default_factory=FacadeQuotaPolicy)
+ rate_limit: Optional[RequestRateLimitPolicy] = None
@dataclass(frozen=True)
@@ -51,6 +53,7 @@ class RequestIdentity:
tags: tuple[str, ...] = ()
default_class: Optional[str] = None
quota_policy: Dict[str, Any] = field(default_factory=dict)
+ rate_limit_policy: Optional[RequestRateLimitPolicy] = None
token_accounting_state: Optional[str] = None
@@ -149,6 +152,14 @@ def _normalize_facade_key_config(key_id: str, raw_config: Any) -> FacadeKeyConfi
tags=_normalize_tags(raw_config.get("tags"), key_id=key_id),
default_class=(str(raw_config.get("default_class")).strip() if raw_config.get("default_class") not in (None, "") else None),
quota=_normalize_quota_policy(raw_config.get("quota"), key_id=key_id),
+ rate_limit=(
+ parse_request_rate_limit_policy(
+ raw_config["rate_limit"],
+ field_name=f"facade_keys.{key_id}.rate_limit",
+ )
+ if raw_config.get("rate_limit") not in (None, "")
+ else None
+ ),
)
@@ -265,6 +276,7 @@ def resolve_secret(self, presented_secret: str) -> Optional[ResolvedCaller]:
tags=config.tags,
default_class=config.default_class,
quota_policy=config.quota.to_dict(),
+ rate_limit_policy=config.rate_limit,
token_accounting_state="untracked",
),
)
@@ -283,6 +295,7 @@ def to_dict(self) -> Dict[str, Dict[str, Any]]:
"tags": list(config.tags),
"default_class": config.default_class,
"quota": config.quota.to_dict(),
+ "rate_limit": config.rate_limit.to_dict() if config.rate_limit is not None else None,
"secret_count": len(self.secrets.get(key_id, ())),
}
for key_id, config in self.configs.items()
diff --git a/smolrouter/request_rate_limits.py b/smolrouter/request_rate_limits.py
new file mode 100644
index 0000000..04b348f
--- /dev/null
+++ b/smolrouter/request_rate_limits.py
@@ -0,0 +1,404 @@
+"""Atomic Redis-backed request rate limiting for anonymous and project traffic."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import math
+import uuid
+from dataclasses import dataclass
+from typing import Any, Mapping
+
+from .redis_config import redis_client
+
+
+logger = logging.getLogger("smolrouter.request_rate_limits")
+_CONFIG_KEYS = frozenset({"enabled", "anonymous", "project_default"})
+_POLICY_KEYS = frozenset({"windows"})
+_WINDOW_KEYS = frozenset({"requests", "seconds"})
+_BUCKET_CLASSES = frozenset({"anonymous", "project"})
+_BUCKET_KEY_PREFIX = "smolrouter:request-rate-limit:bucket"
+_STATS_KEY = "smolrouter:request-rate-limit:stats"
+
+
+class RequestRateLimitConfigError(ValueError):
+ """Raised when request rate-limit configuration violates its schema."""
+
+
+@dataclass(frozen=True, slots=True)
+class RateLimitWindow:
+ requests: int
+ seconds: int
+
+ def to_dict(self) -> dict[str, int]:
+ return {"requests": self.requests, "seconds": self.seconds}
+
+
+@dataclass(frozen=True, slots=True)
+class RequestRateLimitPolicy:
+ windows: tuple[RateLimitWindow, RateLimitWindow]
+
+ def to_dict(self) -> dict[str, list[dict[str, int]]]:
+ return {"windows": [window.to_dict() for window in self.windows]}
+
+
+DEFAULT_REQUEST_RATE_LIMIT_POLICY = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(requests=1, seconds=1), RateLimitWindow(requests=3, seconds=10))
+)
+
+
+@dataclass(frozen=True, slots=True)
+class RequestRateLimitConfig:
+ enabled: bool = True
+ anonymous: RequestRateLimitPolicy = DEFAULT_REQUEST_RATE_LIMIT_POLICY
+ project_default: RequestRateLimitPolicy = DEFAULT_REQUEST_RATE_LIMIT_POLICY
+
+
+@dataclass(frozen=True, slots=True)
+class RateLimitDecision:
+ allowed: bool
+ retry_after_ms: int
+ violated_windows: tuple[RateLimitWindow, ...]
+ counts: tuple[int, int]
+ bucket_class: str
+ policy: RequestRateLimitPolicy
+
+ @property
+ def retry_after_seconds(self) -> int:
+ """Return an HTTP ``Retry-After`` value, rounded up to whole seconds."""
+
+ if self.allowed:
+ return 0
+ return max(1, math.ceil(self.retry_after_ms / 1000))
+
+
+def _require_mapping(raw: Any, field_name: str) -> Mapping[str, Any]:
+ if not isinstance(raw, Mapping):
+ raise RequestRateLimitConfigError(f"{field_name} must be a mapping")
+ return raw
+
+
+def _reject_unknown_keys(raw: Mapping[str, Any], allowed: frozenset[str], field_name: str) -> None:
+ unknown = sorted(set(raw) - allowed, key=repr)
+ if unknown:
+ rendered = ", ".join(str(key) for key in unknown)
+ raise RequestRateLimitConfigError(f"{field_name} contains unknown keys: {rendered}")
+
+
+def _parse_request_rate_limit_window(raw: Any, field_name: str) -> RateLimitWindow:
+ window = _require_mapping(raw, field_name)
+ _reject_unknown_keys(window, _WINDOW_KEYS, field_name)
+ missing = sorted(_WINDOW_KEYS - set(window))
+ if missing:
+ raise RequestRateLimitConfigError(f"{field_name} is missing required keys: {', '.join(missing)}")
+
+ requests = window["requests"]
+ seconds = window["seconds"]
+ if type(requests) is not int or requests <= 0:
+ raise RequestRateLimitConfigError(f"{field_name}.requests must be a positive integer")
+ if type(seconds) is not int or seconds <= 0:
+ raise RequestRateLimitConfigError(f"{field_name}.seconds must be a positive integer")
+ return RateLimitWindow(requests=requests, seconds=seconds)
+
+
+def parse_request_rate_limit_policy(
+ raw: Any,
+ field_name: str,
+ default: RequestRateLimitPolicy | None = None,
+) -> RequestRateLimitPolicy:
+ """Parse one strict, complete two-window rate-limit policy."""
+
+ if raw is None:
+ if default is not None:
+ return default
+ raise RequestRateLimitConfigError(f"{field_name} must be a mapping")
+
+ policy = _require_mapping(raw, field_name)
+ _reject_unknown_keys(policy, _POLICY_KEYS, field_name)
+ if "windows" not in policy:
+ raise RequestRateLimitConfigError(f"{field_name}.windows is required")
+
+ windows_raw = policy["windows"]
+ if type(windows_raw) is not list:
+ raise RequestRateLimitConfigError(f"{field_name}.windows must be a list")
+ if len(windows_raw) != 2:
+ raise RequestRateLimitConfigError(f"{field_name}.windows must contain exactly 2 windows")
+
+ windows = [
+ _parse_request_rate_limit_window(window_raw, f"{field_name}.windows[{index}]")
+ for index, window_raw in enumerate(windows_raw)
+ ]
+
+ short, long = windows
+ if short.seconds >= long.seconds:
+ raise RequestRateLimitConfigError(f"{field_name}.windows durations must be unique and strictly ascending")
+ if short.requests > long.requests:
+ raise RequestRateLimitConfigError(f"{field_name}.windows request capacities must be monotonic")
+
+ return RequestRateLimitPolicy(windows=(short, long))
+
+
+def parse_request_rate_limit_config(raw: Any) -> RequestRateLimitConfig:
+ """Parse the strict top-level request rate-limit configuration."""
+
+ if raw is None:
+ raw = {}
+ config = _require_mapping(raw, "request_rate_limits")
+ _reject_unknown_keys(config, _CONFIG_KEYS, "request_rate_limits")
+
+ enabled = config.get("enabled", True)
+ if type(enabled) is not bool:
+ raise RequestRateLimitConfigError("request_rate_limits.enabled must be a boolean")
+
+ return RequestRateLimitConfig(
+ enabled=enabled,
+ anonymous=parse_request_rate_limit_policy(
+ config["anonymous"] if "anonymous" in config else None,
+ "request_rate_limits.anonymous",
+ DEFAULT_REQUEST_RATE_LIMIT_POLICY if "anonymous" not in config else None,
+ ),
+ project_default=parse_request_rate_limit_policy(
+ config["project_default"] if "project_default" in config else None,
+ "request_rate_limits.project_default",
+ DEFAULT_REQUEST_RATE_LIMIT_POLICY if "project_default" not in config else None,
+ ),
+ )
+
+
+def request_rate_limit_bucket_key(
+ bucket_class: str,
+ *,
+ identity_kind: str | None = None,
+ identity_subject_id: str | None = None,
+) -> str:
+ """Build a stable bucket key without putting identity material into Redis keys."""
+
+ if bucket_class not in _BUCKET_CLASSES:
+ raise ValueError("bucket_class must be 'anonymous' or 'project'")
+
+ if bucket_class == "anonymous":
+ if identity_kind is not None or identity_subject_id is not None:
+ raise ValueError("anonymous buckets do not accept identity fields")
+ canonical = "anonymous:global"
+ else:
+ if not isinstance(identity_kind, str) or not identity_kind:
+ raise ValueError("project buckets require a non-empty identity_kind")
+ if not isinstance(identity_subject_id, str) or not identity_subject_id:
+ raise ValueError("project buckets require a non-empty identity_subject_id")
+ canonical = f"{identity_kind}:{identity_subject_id}"
+
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+ return f"{_BUCKET_KEY_PREFIX}:{bucket_class}:{digest}"
+
+
+_CHECK_RATE_LIMIT_LUA = r"""
+local bucket_key = KEYS[1]
+local member = ARGV[1]
+local short_limit = tonumber(ARGV[2])
+local short_window_ms = tonumber(ARGV[3])
+local long_limit = tonumber(ARGV[4])
+local long_window_ms = tonumber(ARGV[5])
+local supplied_now_ms = ARGV[6]
+
+local now_ms
+if supplied_now_ms ~= "" then
+ now_ms = tonumber(supplied_now_ms)
+else
+ local redis_time = redis.call("TIME")
+ now_ms = (tonumber(redis_time[1]) * 1000) + math.floor(tonumber(redis_time[2]) / 1000)
+end
+
+local short_cutoff = now_ms - short_window_ms
+local long_cutoff = now_ms - long_window_ms
+local short_count = tonumber(redis.call("ZCOUNT", bucket_key, "(" .. short_cutoff, "+inf"))
+local long_count = tonumber(redis.call("ZCOUNT", bucket_key, "(" .. long_cutoff, "+inf"))
+local short_violated = short_count >= short_limit
+local long_violated = long_count >= long_limit
+
+local retry_after_ms = 0
+
+-- Redis returns WITHSCORES as {member, score} inside Lua. Newer FakeRedis
+-- versions model the RESP3 shape as {{member, score}} instead. Accept both so
+-- development and CI exercise the same admission semantics as real Redis.
+local function ranked_event_score(event)
+ if #event == 2 then
+ return tonumber(event[2])
+ end
+ if #event == 1 and type(event[1]) == "table" and #event[1] >= 2 then
+ return tonumber(event[1][2])
+ end
+ return nil
+end
+
+if short_violated then
+ local rank = short_count - short_limit
+ local event = redis.call("ZRANGEBYSCORE", bucket_key, "(" .. short_cutoff, "+inf", "WITHSCORES", "LIMIT", rank, 1)
+ local score = ranked_event_score(event)
+ if score then
+ retry_after_ms = math.max(retry_after_ms, score + short_window_ms - now_ms)
+ end
+end
+
+if long_violated then
+ local rank = long_count - long_limit
+ local event = redis.call("ZRANGEBYSCORE", bucket_key, "(" .. long_cutoff, "+inf", "WITHSCORES", "LIMIT", rank, 1)
+ local score = ranked_event_score(event)
+ if score then
+ retry_after_ms = math.max(retry_after_ms, score + long_window_ms - now_ms)
+ end
+end
+
+if short_violated or long_violated then
+ return {0, retry_after_ms, short_count, long_count, short_violated and 1 or 0, long_violated and 1 or 0}
+end
+
+redis.call("ZREMRANGEBYSCORE", bucket_key, "-inf", long_cutoff)
+redis.call("ZADD", bucket_key, now_ms, member)
+redis.call("PEXPIRE", bucket_key, long_window_ms + 1000)
+return {1, 0, short_count + 1, long_count + 1, 0, 0}
+"""
+
+
+class RedisRequestRateLimiter:
+ """Apply a two-window rolling limit atomically using a Redis sorted set."""
+
+ def __init__(self, client: Any = redis_client):
+ self._client = client
+ self._backend_error_count = 0
+
+ @property
+ def backend_error_count(self) -> int:
+ """Return the bounded-label process-local count of authoritative backend failures."""
+
+ return self._backend_error_count
+
+ @property
+ def stats(self) -> dict[str, int]:
+ """Return process-local limiter outcomes that remain available during Redis outages."""
+
+ return {"backend_error_count": self._backend_error_count}
+
+ async def verify(self) -> None:
+ """Exercise the production Lua/ZSET/TTL path using an ephemeral bucket."""
+
+ await self._client.ping()
+ verification_key = f"{_BUCKET_KEY_PREFIX}:verify:{uuid.uuid4().hex}"
+ short, long = DEFAULT_REQUEST_RATE_LIMIT_POLICY.windows
+ try:
+ result = await self._client.eval(
+ _CHECK_RATE_LIMIT_LUA,
+ 1,
+ verification_key,
+ uuid.uuid4().hex,
+ short.requests,
+ short.seconds * 1000,
+ long.requests,
+ long.seconds * 1000,
+ "",
+ )
+ if not result or int(result[0]) != 1:
+ raise RuntimeError("request rate limiter verification was not admitted")
+ if await self._client.zcard(verification_key) != 1:
+ raise RuntimeError("request rate limiter verification did not write its event")
+ pttl = await self._client.pttl(verification_key)
+ expected_pttl = long.seconds * 1000 + 1000
+ if not 0 < pttl <= expected_pttl:
+ raise RuntimeError("request rate limiter verification did not set a TTL")
+ except BaseException:
+ try:
+ await self._client.delete(verification_key)
+ except Exception:
+ logger.warning("Request rate-limit verification cleanup failed after verification error")
+ raise
+ else:
+ await self._client.delete(verification_key)
+
+ async def check(
+ self,
+ bucket_class: str,
+ policy: RequestRateLimitPolicy,
+ identity_kind: str | None = None,
+ identity_subject_id: str | None = None,
+ ) -> RateLimitDecision:
+ return await self._check(
+ bucket_class,
+ policy,
+ identity_kind=identity_kind,
+ identity_subject_id=identity_subject_id,
+ now_ms=None,
+ )
+
+ async def _check_at_ms(
+ self,
+ bucket_class: str,
+ policy: RequestRateLimitPolicy,
+ now_ms: int,
+ identity_kind: str | None = None,
+ identity_subject_id: str | None = None,
+ ) -> RateLimitDecision:
+ """Deterministic test seam; production checks always use Redis TIME."""
+
+ if not isinstance(now_ms, int) or isinstance(now_ms, bool) or now_ms < 0:
+ raise ValueError("now_ms must be a non-negative integer")
+ return await self._check(
+ bucket_class,
+ policy,
+ identity_kind=identity_kind,
+ identity_subject_id=identity_subject_id,
+ now_ms=now_ms,
+ )
+
+ async def _check(
+ self,
+ bucket_class: str,
+ policy: RequestRateLimitPolicy,
+ *,
+ identity_kind: str | None,
+ identity_subject_id: str | None,
+ now_ms: int | None,
+ ) -> RateLimitDecision:
+ if not isinstance(policy, RequestRateLimitPolicy):
+ raise TypeError("policy must be a RequestRateLimitPolicy")
+
+ bucket_key = request_rate_limit_bucket_key(
+ bucket_class,
+ identity_kind=identity_kind,
+ identity_subject_id=identity_subject_id,
+ )
+ short, long = policy.windows
+ try:
+ result = await self._client.eval(
+ _CHECK_RATE_LIMIT_LUA,
+ 1,
+ bucket_key,
+ uuid.uuid4().hex,
+ short.requests,
+ short.seconds * 1000,
+ long.requests,
+ long.seconds * 1000,
+ "" if now_ms is None else now_ms,
+ )
+ except Exception:
+ self._backend_error_count += 1
+ logger.warning("Request rate-limit backend check failed")
+ raise
+ allowed = bool(int(result[0]))
+ violations = tuple(
+ window for window, violated in zip(policy.windows, result[4:6], strict=True) if bool(int(violated))
+ )
+ decision = RateLimitDecision(
+ allowed=allowed,
+ retry_after_ms=max(0, int(result[1])),
+ violated_windows=violations,
+ counts=(int(result[2]), int(result[3])),
+ bucket_class=bucket_class,
+ policy=policy,
+ )
+
+ stats_field = f"{'admitted' if allowed else 'rejected'}:{bucket_class}"
+ try:
+ await self._client.hincrby(_STATS_KEY, stats_field, 1)
+ except Exception:
+ # Admission is authoritative; bounded aggregate telemetry is best effort.
+ logger.debug("Request rate-limit aggregate stats update failed", exc_info=True)
+ return decision
diff --git a/smolrouter/templates/project_detail.html b/smolrouter/templates/project_detail.html
index dc01d8a..6911b5b 100644
--- a/smolrouter/templates/project_detail.html
+++ b/smolrouter/templates/project_detail.html
@@ -145,8 +145,16 @@
{{ project.display_name or project.id }}
{{ project.quota.action if project.quota else "observe" }}
-
Records Loaded
-
{{ logs | length }}
+
Request Limit
+
+ {% if project.request_rate_limit.enabled and project.request_rate_limit.policy %}
+ {% for window in project.request_rate_limit.policy.windows %}
+
{{ window.requests }} / {{ window.seconds }}s
+ {% endfor %}
+ {% else %}
+ disabled
+ {% endif %}
+
@@ -159,7 +167,7 @@ Recent Facade-Key Requests
- This page shows recent requests attributed to facade_key:{{ project.id }} within the retained request-log window. It does not provide quota accounting or enforcement yet.
+ This page shows recent requests attributed to facade_key:{{ project.id }} within the retained request-log window. The displayed {{ project.request_rate_limit.source | default("historical", true) | replace('_', ' ') }} request policy is effective configuration, not a live Redis counter.
diff --git a/smolrouter/templates/projects.html b/smolrouter/templates/projects.html
index 8d66adb..c89878b 100644
--- a/smolrouter/templates/projects.html
+++ b/smolrouter/templates/projects.html
@@ -102,7 +102,7 @@
Facade-Key Projects
- This inventory lists configured facade-key identities only. Quota values shown here are configured policy, not live accounting or enforcement. Historical traffic for identities with no current config is reachable from request or project drilldown links, but is not listed in this inventory.
+ This inventory lists configured facade-key identities and their effective two-window request limits. The values are policy, not live counters; request history is limited to the retained request-log window.
@@ -113,6 +113,7 @@
Facade-Key Projects
Identity |
Tags |
Quota |
+
Request limit |
Secrets |
Status |
@@ -151,6 +152,16 @@
Facade-Key Projects
default
{% endif %}
+
+ {% if project.request_rate_limit.enabled and project.request_rate_limit.policy %}
+ {% for window in project.request_rate_limit.policy.windows %}
+ {{ window.requests }} request{{ "s" if window.requests != 1 else "" }} / {{ window.seconds }}s
+ {% endfor %}
+ {{ (project.request_rate_limit.source or "") | replace('_', ' ') }}
+ {% else %}
+ disabled
+ {% endif %}
+ |
{{ project.secret_count }} |
{% if project.configured %}
@@ -166,7 +177,7 @@ Facade-Key Projects
{% else %}
|
- | No projects are configured |
+ No projects are configured |
{% endfor %}
diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py
index eeb85e7..b2eb572 100644
--- a/tests/unit/test_app.py
+++ b/tests/unit/test_app.py
@@ -6,6 +6,7 @@
from urllib.parse import quote
from types import SimpleNamespace
import smolrouter.database as database
+import smolrouter.container as container_module
from pydantic import BaseModel
import smolrouter.app as app_module
from smolrouter.app import (
@@ -35,6 +36,13 @@
from smolrouter.google_genai_provider import GoogleGenAIConfig, GoogleGenAIProvider
from smolrouter.interfaces import ProxyConfig
from smolrouter.request_metadata import RequestMetadata, apply_request_metadata
+from smolrouter.request_rate_limits import (
+ RateLimitDecision,
+ RateLimitWindow,
+ RequestRateLimitConfig,
+ RequestRateLimitConfigError,
+ RequestRateLimitPolicy,
+)
from smolrouter.task_utils import create_logged_task
from smolrouter.providers import OpenAIProvider, ProviderConfig
from starlette.requests import Request
@@ -428,6 +436,7 @@ async def test_openai_proxy_request_with_nonlocal_authorization_keeps_anonymous_
):
captured = {}
fake_container = Mock()
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
async def fake_route_request(*args, **kwargs):
captured["client_context"] = kwargs.get("client_context")
@@ -454,7 +463,7 @@ async def fake_get_active_container(_legacy_proxy: bool):
)
assert response.status_code == 200
- assert captured["client_context"] is None
+ assert captured["client_context"].identity is None
assert captured["headers"] == {"authorization": "Bearer provider-token"}
@@ -720,6 +729,7 @@ async def test_openai_proxy_request_without_facade_key_keeps_client_context_none
):
captured = {}
fake_container = Mock()
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
async def fake_route_request(*args, **kwargs):
captured["client_context"] = kwargs["client_context"]
@@ -744,7 +754,7 @@ async def fake_get_active_container(_legacy_proxy: bool):
)
assert response.status_code == 200
- assert captured["client_context"] is None
+ assert captured["client_context"].identity is None
@pytest.mark.asyncio
@@ -830,12 +840,14 @@ async def test_openai_proxy_request_rejects_unknown_facade_key_in_non_legacy_mod
fake_container.get_facade_key_registry.return_value = registry
fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
fake_container.route_request = AsyncMock()
+ limiter = SimpleNamespace(check=AsyncMock())
async def fake_get_active_container(_legacy_proxy: bool):
return fake_container
monkeypatch.setattr(app_module, "_is_legacy_proxy_mode", lambda: False)
monkeypatch.setattr(app_module, "_get_active_container", fake_get_active_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
response = await async_client.post(
"/v1/chat/completions",
@@ -845,6 +857,7 @@ async def fake_get_active_container(_legacy_proxy: bool):
assert response.status_code == 401
assert response.json()["error"] == "facade_key_auth_failed"
+ limiter.check.assert_not_awaited()
fake_container.route_request.assert_not_awaited()
@@ -854,6 +867,7 @@ async def test_openai_proxy_request_treats_whitespace_only_facade_key_as_absent(
):
captured = {}
fake_container = Mock()
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
async def fake_route_request(*args, **kwargs):
captured["client_context"] = kwargs["client_context"]
@@ -879,7 +893,7 @@ async def fake_get_active_container(_legacy_proxy: bool):
)
assert response.status_code == 200
- assert captured["client_context"] is None
+ assert captured["client_context"].identity is None
@pytest.mark.asyncio
@@ -932,6 +946,339 @@ async def test_openai_proxy_request_rejects_facade_key_in_legacy_mode(async_clie
assert response.json()["detail"] == "SmolRouter facade keys are unavailable in legacy proxy mode"
+def _rate_limit_decision(*, allowed: bool, bucket_class: str = "anonymous") -> RateLimitDecision:
+ policy = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(1, 1), RateLimitWindow(3, 10)),
+ )
+ return RateLimitDecision(
+ allowed=allowed,
+ retry_after_ms=1250 if not allowed else 0,
+ violated_windows=(policy.windows[0],) if not allowed else (),
+ counts=(1, 1),
+ bucket_class=bucket_class,
+ policy=policy,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/responses",
+ "/v1/embeddings",
+ "/v1/images/generations",
+ ],
+)
+async def test_proxy_backed_routes_enforce_rate_limit_before_body_parsing(
+ path, async_client, disable_logging, monkeypatch
+):
+ limiter = SimpleNamespace(check=AsyncMock(return_value=_rate_limit_decision(allowed=False)))
+ parse_payload = AsyncMock(side_effect=AssertionError("body must not be parsed"))
+ route_upstream = AsyncMock(side_effect=AssertionError("upstream must not be called"))
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "_parse_openai_request_payload", parse_payload)
+ monkeypatch.setattr(app_module, "_route_openai_request", route_upstream)
+
+ response = await async_client.post(path, content=b'{"secret":"must-not-be-read"}')
+
+ assert response.status_code == 429
+ assert response.json()["error"]["code"] == "anonymous_rate_limit_exceeded"
+ assert response.headers["retry-after"] == "2"
+ assert response.headers["x-smolrouter-ratelimit-bucket"] == "anonymous"
+ assert response.headers["x-smolrouter-ratelimit-policy"] == "1;w=1, 3;w=10"
+ assert "secret" not in str(response.headers)
+ parse_payload.assert_not_awaited()
+ route_upstream.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_image_edits_and_variations_are_outside_request_limiter(async_client, disable_logging, monkeypatch):
+ limiter = SimpleNamespace(check=AsyncMock(side_effect=AssertionError("limiter must not run")))
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+
+ for path in ("/v1/images/edits", "/v1/images/variations"):
+ response = await async_client.post(path, content=b"not-json")
+ assert response.status_code == 501
+
+ limiter.check.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_rate_limiter_failure_returns_stable_503_before_parse_or_upstream(
+ async_client, disable_logging, monkeypatch, caplog
+):
+ limiter = SimpleNamespace(check=AsyncMock(side_effect=ConnectionError("redis password=secret")))
+ parse_payload = AsyncMock(side_effect=AssertionError("body must not be parsed"))
+ route_upstream = AsyncMock(side_effect=AssertionError("upstream must not be called"))
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "_parse_openai_request_payload", parse_payload)
+ monkeypatch.setattr(app_module, "_route_openai_request", route_upstream)
+
+ with caplog.at_level(logging.INFO, logger="model-rerouter"):
+ response = await async_client.post("/v1/responses", content=b'{"credential":"secret"}')
+
+ assert response.status_code == 503
+ assert response.json()["error"]["code"] == "rate_limiter_unavailable"
+ assert "secret" not in response.text
+ outcome_log = next(record.message for record in caplog.records if "outcome=unavailable" in record.message)
+ assert "status=error" in outcome_log
+ assert "effective_policy=" in outcome_log
+ assert "violated_windows=unknown" in outcome_log
+ assert "retry_after_ms=unknown" in outcome_log
+ assert "identity_" not in outcome_log
+ assert "secret" not in outcome_log
+ parse_payload.assert_not_awaited()
+ route_upstream.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_disabled_request_limiter_skips_check(async_client, mock_openai_upstream, disable_logging, monkeypatch):
+ limiter = SimpleNamespace(check=AsyncMock(side_effect=AssertionError("disabled limiter must not run")))
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = RequestRateLimitConfig(enabled=False)
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "container", fake_container)
+
+ response = await async_client.post(
+ "/v1/chat/completions",
+ json={"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]},
+ )
+
+ assert response.status_code == 200
+ limiter.check.assert_not_awaited()
+
+
+def test_request_rate_limit_config_preserves_disabled_setting_without_container(monkeypatch):
+ monkeypatch.setattr(app_module, "ROUTES_CONFIG_DATA", {"routes": [], "request_rate_limits": {"enabled": False}})
+
+ assert app_module._request_rate_limit_config().enabled is False
+
+
+@pytest.mark.asyncio
+async def test_identified_request_without_container_uses_project_default(monkeypatch):
+ project_default = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(4, 2), RateLimitWindow(12, 20)),
+ )
+ config = RequestRateLimitConfig(project_default=project_default)
+ limiter = SimpleNamespace(check=AsyncMock(return_value=_rate_limit_decision(allowed=True, bucket_class="project")))
+ request = Mock()
+ request.method = "POST"
+ request.url = SimpleNamespace(path="/v1/chat/completions")
+ request.headers = {}
+ request.state = SimpleNamespace()
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "_request_rate_limit_config", lambda _container=None: config)
+
+ response = await app_module._apply_request_rate_limit(
+ request,
+ 0.0,
+ None,
+ app_module.ProxyRequestContext(
+ headers={},
+ identity=RequestIdentity(kind="facade_key", subject_id="project-a"),
+ ),
+ )
+
+ assert response is None
+ limiter.check.assert_awaited_once_with(
+ "project",
+ project_default,
+ identity_kind="facade_key",
+ identity_subject_id="project-a",
+ )
+
+
+@pytest.mark.asyncio
+async def test_legacy_anonymous_request_uses_configured_custom_policy(
+ async_client, mock_openai_upstream, disable_logging, monkeypatch
+):
+ custom_policy = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(7, 2), RateLimitWindow(30, 20)),
+ )
+ config = RequestRateLimitConfig(anonymous=custom_policy)
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = config
+ fake_container.get_effective_request_rate_limit_policy.return_value = custom_policy
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
+ limiter = SimpleNamespace(check=AsyncMock(return_value=_rate_limit_decision(allowed=True)))
+ monkeypatch.setattr(app_module, "container", fake_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+
+ response = await async_client.post(
+ "/v1/chat/completions",
+ json={"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]},
+ )
+
+ assert response.status_code == 200
+ limiter.check.assert_awaited_once_with("anonymous", custom_policy)
+
+
+@pytest.mark.asyncio
+async def test_anonymous_bucket_is_global_across_source_ips(disable_logging, monkeypatch):
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", app_module.RedisRequestRateLimiter())
+
+ def request_from(source_ip: str):
+ request = Mock()
+ request.client = SimpleNamespace(host=source_ip)
+ request.method = "POST"
+ request.url = SimpleNamespace(path="/v1/chat/completions")
+ request.headers = {}
+ request.state = SimpleNamespace()
+ return request
+
+ first = await app_module._apply_request_rate_limit(
+ request_from("192.0.2.1"),
+ 0.0,
+ None,
+ app_module.ProxyRequestContext(headers={}),
+ )
+ second = await app_module._apply_request_rate_limit(
+ request_from("198.51.100.2"),
+ 0.0,
+ None,
+ app_module.ProxyRequestContext(headers={}),
+ )
+
+ assert first is None
+ assert second is not None
+ assert second.status_code == 429
+ assert b"anonymous request rate limit" in second.body
+
+
+@pytest.mark.asyncio
+async def test_project_rate_limit_buckets_are_isolated(disable_logging, monkeypatch):
+ config = RequestRateLimitConfig()
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = config
+ fake_container.get_effective_request_rate_limit_policy.return_value = config.project_default
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", app_module.RedisRequestRateLimiter())
+
+ request = Mock()
+ request.client = SimpleNamespace(host="192.0.2.1")
+ request.method = "POST"
+ request.url = SimpleNamespace(path="/v1/chat/completions")
+ request.headers = {}
+ request.state = SimpleNamespace()
+
+ first = await app_module._apply_request_rate_limit(
+ request,
+ 0.0,
+ None,
+ app_module.ProxyRequestContext(
+ headers={},
+ active_container=fake_container,
+ identity=RequestIdentity(kind="facade_key", subject_id="project-a"),
+ ),
+ )
+ second = await app_module._apply_request_rate_limit(
+ request,
+ 0.0,
+ None,
+ app_module.ProxyRequestContext(
+ headers={},
+ active_container=fake_container,
+ identity=RequestIdentity(kind="facade_key", subject_id="project-b"),
+ ),
+ )
+
+ assert first is None
+ assert second is None
+
+
+@pytest.mark.asyncio
+async def test_project_override_policy_and_identity_bucket_are_passed_to_limiter(
+ async_client, disable_logging, monkeypatch
+):
+ override = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(5, 2), RateLimitWindow(20, 30)),
+ )
+ default = RequestRateLimitPolicy(
+ windows=(RateLimitWindow(2, 1), RateLimitWindow(8, 10)),
+ )
+ config = RequestRateLimitConfig(project_default=default)
+ registry = FacadeKeyRegistry.from_sources(
+ facade_key_configs={
+ "project-a": {
+ "rate_limit": {"windows": [{"requests": 5, "seconds": 2}, {"requests": 20, "seconds": 30}]}
+ }
+ },
+ facade_key_secrets={"project-a": ["srk-project-secret"]},
+ )
+ fake_container = Mock()
+ fake_container.get_facade_key_registry.return_value = registry
+ fake_container.get_request_rate_limit_config.return_value = config
+ fake_container.get_effective_request_rate_limit_policy.side_effect = lambda identity: (
+ identity.rate_limit_policy or default
+ )
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
+ fake_container.route_request = AsyncMock(return_value=({"choices": []}, 200, "provider:test", None))
+ limiter = SimpleNamespace(check=AsyncMock(return_value=_rate_limit_decision(allowed=True, bucket_class="project")))
+
+ async def fake_get_active_container(_legacy_proxy: bool):
+ return fake_container
+
+ monkeypatch.setattr(app_module, "_is_legacy_proxy_mode", lambda: False)
+ monkeypatch.setattr(app_module, "_get_active_container", fake_get_active_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+
+ response = await async_client.post(
+ "/v1/chat/completions",
+ json={"model": "gpt-4", "messages": []},
+ headers={app_module.FACADE_KEY_HEADER: "srk-project-secret"},
+ )
+
+ assert response.status_code == 200
+ limiter.check.assert_awaited_once_with(
+ "project",
+ override,
+ identity_kind="facade_key",
+ identity_subject_id="project-a",
+ )
+
+
+@pytest.mark.asyncio
+async def test_rate_limited_project_request_is_logged_without_request_body(
+ async_client, isolated_db, monkeypatch
+):
+ monkeypatch.setattr(app_module, "ENABLE_LOGGING", True)
+ registry = FacadeKeyRegistry.from_sources(
+ facade_key_configs={"project-a": {}},
+ facade_key_secrets={"project-a": ["srk-project-secret"]},
+ )
+ config = RequestRateLimitConfig()
+ fake_container = Mock()
+ fake_container.get_facade_key_registry.return_value = registry
+ fake_container.get_request_rate_limit_config.return_value = config
+ fake_container.get_effective_request_rate_limit_policy.return_value = config.project_default
+ fake_container.create_client_context.side_effect = lambda **kwargs: ClientContext(**kwargs)
+ fake_container.route_request = AsyncMock()
+ limiter = SimpleNamespace(check=AsyncMock(return_value=_rate_limit_decision(allowed=False, bucket_class="project")))
+
+ async def fake_get_active_container(_legacy_proxy: bool):
+ return fake_container
+
+ monkeypatch.setattr(app_module, "_is_legacy_proxy_mode", lambda: False)
+ monkeypatch.setattr(app_module, "_get_active_container", fake_get_active_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+
+ response = await async_client.post(
+ "/v1/embeddings",
+ content=b'{"api_key":"never-store-this"}',
+ headers={app_module.FACADE_KEY_HEADER: "srk-project-secret"},
+ )
+
+ assert response.status_code == 429
+ records = await app_module.RequestLog.get_recent(1)
+ assert records[0].status_code == 429
+ assert records[0].identity_subject_id == "project-a"
+ assert records[0].request_size == 0
+ assert "project-secret" not in str(response.headers)
+ fake_container.route_request.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_ollama_generate_non_streaming(async_client, mock_openai_upstream, disable_logging):
response = await async_client.post(
@@ -1483,8 +1830,8 @@ async def _get_active_container(_legacy_proxy: bool):
projects_body = projects_response.text
assert "Project A" in projects_body
assert "project-a" in projects_body
- assert "configured facade-key identities only" in projects_body
- assert "not live accounting or enforcement" in projects_body
+ assert "configured facade-key identities" in projects_body
+ assert "effective two-window request limits" in projects_body
assert "srk-project-a-1" not in projects_body
assert "srk-project-a-2" not in projects_body
@@ -1497,6 +1844,17 @@ async def _get_active_container(_legacy_proxy: bool):
assert project["identity"]["kind"] == "facade_key"
assert project["identity"]["canonical"] == "facade_key:project-a"
assert project["secret_count"] == 2
+ assert project["request_rate_limit"] == {
+ "enabled": True,
+ "source": "project_default",
+ "policy": {
+ "windows": [
+ {"requests": 1, "seconds": 1},
+ {"requests": 3, "seconds": 10},
+ ]
+ },
+ }
+ assert "1 request / 1s" in projects_body
assert "srk-project-a" not in str(api_payload)
@@ -1565,7 +1923,7 @@ async def _get_active_container(_legacy_proxy: bool):
assert page.status_code == 200
assert "legacy-project" in page.text
assert "No current config" in page.text
- assert "does not provide quota accounting or enforcement yet" in page.text
+ assert "not a live Redis counter" in page.text
def test_openapi_documents_proxy_and_project_api_error_responses():
@@ -1575,12 +1933,14 @@ def test_openapi_documents_proxy_and_project_api_error_responses():
chat_responses = schema["paths"]["/v1/chat/completions"]["post"]["responses"]
assert chat_responses["400"]["description"] == "Invalid request payload"
assert chat_responses["401"]["description"] == "Invalid or mismatched local facade key"
- assert chat_responses["503"]["description"] == "Routing unavailable"
+ assert chat_responses["429"]["description"] == "Project or anonymous request rate limit exceeded"
+ assert chat_responses["503"]["description"] == "Routing or request rate limiter unavailable"
assert "/v1/embeddings" in schema["paths"]
embeddings_responses = schema["paths"]["/v1/embeddings"]["post"]["responses"]
assert embeddings_responses["400"]["description"] == "Invalid request payload"
assert embeddings_responses["401"]["description"] == "Invalid or mismatched local facade key"
- assert embeddings_responses["503"]["description"] == "Routing unavailable"
+ assert embeddings_responses["429"]["description"] == "Project or anonymous request rate limit exceeded"
+ assert embeddings_responses["503"]["description"] == "Routing or request rate limiter unavailable"
projects_responses = schema["paths"]["/api/projects"]["get"]["responses"]
assert projects_responses["500"]["description"] == "Failed to load project inventory"
@@ -1591,7 +1951,8 @@ def test_openapi_documents_proxy_and_project_api_error_responses():
image_generations_responses = schema["paths"]["/v1/images/generations"]["post"]["responses"]
assert image_generations_responses["400"]["description"] == "Invalid request payload"
assert image_generations_responses["401"]["description"] == "Invalid or mismatched local facade key"
- assert image_generations_responses["503"]["description"] == "Routing unavailable"
+ assert image_generations_responses["429"]["description"] == "Project or anonymous request rate limit exceeded"
+ assert image_generations_responses["503"]["description"] == "Routing or request rate limiter unavailable"
image_edits_responses = schema["paths"]["/v1/images/edits"]["post"]["responses"]
assert image_edits_responses["501"]["description"] == "Image edits and variations are not implemented"
@@ -1785,6 +2146,118 @@ async def test_app_lifespan_rolls_back_logging_cleanup_when_startup_fails(monkey
assert drain_calls == ["drained"]
+@pytest.mark.asyncio
+async def test_request_rate_limiter_startup_verifies_when_enabled(monkeypatch):
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = RequestRateLimitConfig(enabled=True)
+ limiter = SimpleNamespace(verify=AsyncMock(return_value=None))
+ monkeypatch.setattr(app_module, "container", fake_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "is_fake_redis", lambda: False)
+
+ await app_module._verify_request_rate_limiter_startup()
+
+ limiter.verify.assert_awaited_once_with()
+
+
+@pytest.mark.asyncio
+async def test_request_rate_limiter_startup_skips_verify_when_disabled(monkeypatch):
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = RequestRateLimitConfig(enabled=False)
+ limiter = SimpleNamespace(verify=AsyncMock())
+ monkeypatch.setattr(app_module, "container", fake_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+
+ await app_module._verify_request_rate_limiter_startup()
+
+ limiter.verify.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("app_env", ["prod", "production"])
+async def test_request_rate_limiter_startup_rejects_fake_redis_in_production(monkeypatch, app_env):
+ fake_container = Mock()
+ fake_container.get_request_rate_limit_config.return_value = RequestRateLimitConfig(enabled=True)
+ limiter = SimpleNamespace(verify=AsyncMock())
+ monkeypatch.setattr(app_module, "container", fake_container)
+ monkeypatch.setattr(app_module, "REQUEST_RATE_LIMITER", limiter)
+ monkeypatch.setattr(app_module, "is_fake_redis", lambda: True)
+ monkeypatch.setenv("APP_ENV", app_env)
+
+ with pytest.raises(RuntimeError, match="FakeRedis cannot enforce"):
+ await app_module._verify_request_rate_limiter_startup()
+
+ limiter.verify.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_app_lifespan_aborts_when_request_limiter_verification_fails(monkeypatch):
+ monkeypatch.setattr(app_module, "container", None)
+ monkeypatch.setattr(app_module, "ENABLE_LOGGING", False)
+ monkeypatch.setattr(app_module, "_initialize_lua_scripting", AsyncMock(return_value=None))
+ monkeypatch.setattr(app_module, "_initialize_request_logging_system", AsyncMock(return_value=None))
+ monkeypatch.setattr(app_module, "_initialize_blob_storage_strict", Mock(return_value=None))
+ monkeypatch.setattr(app_module, "init_new_architecture", AsyncMock(return_value=None))
+ monkeypatch.setattr(
+ app_module,
+ "_verify_request_rate_limiter_startup",
+ AsyncMock(side_effect=ConnectionError("redis unavailable")),
+ )
+
+ lifespan = app_module.app_lifespan(app)
+ with pytest.raises(ConnectionError, match="redis unavailable"):
+ await lifespan.__aenter__()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("config_text", "error_match"),
+ [
+ (
+ """routes: []
+request_rate_limits:
+ anonymous:
+ windows:
+ - requests: 1
+ seconds: 1
+""",
+ "anonymous.windows must contain exactly 2 windows",
+ ),
+ (
+ """routes: []
+facade_keys:
+ broken-project:
+ rate_limit:
+ windows:
+ - requests: 2
+ seconds: 10
+ - requests: 1
+ seconds: 20
+""",
+ "broken-project.rate_limit.windows request capacities must be monotonic",
+ ),
+ ],
+)
+async def test_app_lifespan_aborts_for_malformed_request_rate_limit_config(
+ tmp_path, monkeypatch, config_text, error_match
+):
+ routes_path = tmp_path / "routes.yaml"
+ routes_path.write_text(config_text)
+ monkeypatch.setenv("ROUTES_CONFIG", str(routes_path))
+ monkeypatch.setattr(app_module, "container", None)
+ monkeypatch.setattr(container_module, "_container", None)
+ monkeypatch.setattr(app_module, "ENABLE_LOGGING", False)
+ monkeypatch.setattr(app_module, "_initialize_lua_scripting", AsyncMock(return_value=None))
+ monkeypatch.setattr(app_module, "_initialize_request_logging_system", AsyncMock(return_value=None))
+ monkeypatch.setattr(app_module, "_initialize_blob_storage_strict", Mock(return_value=None))
+
+ lifespan = app_module.app_lifespan(app)
+ with pytest.raises(RequestRateLimitConfigError, match=error_match):
+ await lifespan.__aenter__()
+
+ assert app_module.container is None
+
+
def test_rewrite_model_exact_match():
original_model_map = MODEL_MAP.copy()
MODEL_MAP.update({"old-model": "new-model"})
diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py
index d16dd04..baece5f 100644
--- a/tests/unit/test_container.py
+++ b/tests/unit/test_container.py
@@ -6,6 +6,7 @@
import smolrouter.container as container_module
from smolrouter.container import SmolRouterConfig, SmolRouterContainer
from smolrouter.facade_keys import RequestIdentity
+from smolrouter.request_rate_limits import parse_request_rate_limit_policy
@pytest.fixture(autouse=True)
@@ -60,7 +61,8 @@ def test_container_builds_facade_key_registry_from_config(monkeypatch):
monkeypatch.setattr(
"smolrouter.container.load_facade_key_registry",
- lambda facade_key_configs: captured.append(dict(facade_key_configs or {})) or container_module.FacadeKeyRegistry.from_sources(
+ lambda facade_key_configs: captured.append(dict(facade_key_configs or {}))
+ or container_module.FacadeKeyRegistry.from_sources(
facade_key_configs=facade_key_configs,
facade_key_secrets={"project-a": ["srk-a"]} if facade_key_configs else {},
),
@@ -84,7 +86,8 @@ def test_container_uses_shared_facade_key_loader_for_empty_config(monkeypatch):
monkeypatch.setattr(
"smolrouter.container.load_facade_key_registry",
- lambda facade_key_configs: captured.append(dict(facade_key_configs or {})) or container_module.FacadeKeyRegistry.from_sources(),
+ lambda facade_key_configs: captured.append(dict(facade_key_configs or {}))
+ or container_module.FacadeKeyRegistry.from_sources(),
)
container = SmolRouterContainer(SmolRouterConfig(providers=[], facade_keys={}))
@@ -115,3 +118,119 @@ async def test_container_route_request_forwards_client_context_to_mediator():
assert result == ({"ok": True}, 200, "provider:test", None)
assert container._mediator.route_request.await_args.kwargs["client_context"] == client_context
+
+
+def test_config_defaults_to_enabled_two_window_request_limits():
+ config = SmolRouterConfig(providers=[])
+
+ assert config.request_rate_limits == {}
+ assert config.request_rate_limit_config.enabled is True
+ assert config.request_rate_limit_config.anonymous.to_dict() == {
+ "windows": [
+ {"requests": 1, "seconds": 1},
+ {"requests": 3, "seconds": 10},
+ ]
+ }
+ assert config.request_rate_limit_config.project_default == config.request_rate_limit_config.anonymous
+
+
+def test_config_parses_request_rate_limit_overrides_and_preserves_raw_config():
+ raw = {
+ "enabled": False,
+ "anonymous": {
+ "windows": [
+ {"requests": 2, "seconds": 1},
+ {"requests": 8, "seconds": 10},
+ ]
+ },
+ "project_default": {
+ "windows": [
+ {"requests": 10, "seconds": 1},
+ {"requests": 50, "seconds": 10},
+ ]
+ },
+ }
+
+ config = SmolRouterConfig(providers=[], request_rate_limits=raw)
+
+ assert config.request_rate_limits is raw
+ assert config.request_rate_limit_config.enabled is False
+ assert config.request_rate_limit_config.anonymous.to_dict() == raw["anonymous"]
+ assert config.request_rate_limit_config.project_default.to_dict() == raw["project_default"]
+
+
+def test_config_fails_fast_on_malformed_request_rate_limits():
+ with pytest.raises(ValueError, match="request_rate_limits.anonymous"):
+ SmolRouterConfig(
+ providers=[],
+ request_rate_limits={
+ "anonymous": {"windows": [{"requests": 1, "seconds": 1}]},
+ },
+ )
+
+
+def test_default_config_loads_top_level_request_rate_limits(monkeypatch):
+ raw = {
+ "enabled": True,
+ "anonymous": {
+ "windows": [
+ {"requests": 2, "seconds": 1},
+ {"requests": 7, "seconds": 10},
+ ]
+ },
+ }
+ monkeypatch.setattr(SmolRouterContainer, "_load_routes_config", lambda self, path: {"request_rate_limits": raw})
+
+ config = SmolRouterContainer.__new__(SmolRouterContainer)._create_default_config()
+
+ assert config.request_rate_limits is raw
+ assert config.request_rate_limit_config.anonymous.to_dict() == raw["anonymous"]
+
+
+def test_container_resolves_anonymous_default_and_project_override_policies():
+ container = SmolRouterContainer(
+ SmolRouterConfig(
+ providers=[],
+ request_rate_limits={
+ "anonymous": {
+ "windows": [
+ {"requests": 1, "seconds": 1},
+ {"requests": 3, "seconds": 10},
+ ]
+ },
+ "project_default": {
+ "windows": [
+ {"requests": 4, "seconds": 1},
+ {"requests": 20, "seconds": 10},
+ ]
+ },
+ },
+ )
+ )
+ override = parse_request_rate_limit_policy(
+ {
+ "windows": [
+ {"requests": 8, "seconds": 1},
+ {"requests": 40, "seconds": 10},
+ ]
+ },
+ field_name="test.override",
+ )
+
+ assert container.get_effective_request_rate_limit_policy(None).to_dict()["windows"][0]["requests"] == 1
+ assert (
+ container.get_effective_request_rate_limit_policy(
+ RequestIdentity(kind="facade_key", subject_id="project-a")
+ ).to_dict()["windows"][0]["requests"]
+ == 4
+ )
+ assert (
+ container.get_effective_request_rate_limit_policy(
+ RequestIdentity(
+ kind="facade_key",
+ subject_id="project-b",
+ rate_limit_policy=override,
+ )
+ )
+ == override
+ )
diff --git a/tests/unit/test_facade_keys.py b/tests/unit/test_facade_keys.py
index 687f6a3..fa76c04 100644
--- a/tests/unit/test_facade_keys.py
+++ b/tests/unit/test_facade_keys.py
@@ -162,3 +162,57 @@ def test_registry_resolves_presented_secret_to_identity():
assert resolved.identity.display_name == "Project A"
assert resolved.identity.tags == ("team:ml",)
assert resolved.identity.quota_policy["daily_tokens_soft"] == 1000
+
+
+def test_registry_parses_complete_project_rate_limit_override():
+ registry = FacadeKeyRegistry.from_sources(
+ facade_key_configs={
+ "project-a": {
+ "rate_limit": {
+ "windows": [
+ {"requests": 5, "seconds": 1},
+ {"requests": 30, "seconds": 10},
+ ]
+ }
+ }
+ },
+ facade_key_secrets={"project-a": ["srk-a"]},
+ )
+
+ config = registry.get_config("project-a")
+ assert config is not None
+ assert config.rate_limit is not None
+ assert config.rate_limit.to_dict() == {
+ "windows": [
+ {"requests": 5, "seconds": 1},
+ {"requests": 30, "seconds": 10},
+ ]
+ }
+ assert registry.to_dict()["project-a"]["rate_limit"] == config.rate_limit.to_dict()
+ assert registry.resolve_secret("srk-a").identity.rate_limit_policy == config.rate_limit
+
+
+def test_registry_keeps_missing_project_rate_limit_as_inherited():
+ registry = FacadeKeyRegistry.from_sources(
+ facade_key_configs={"project-a": {}},
+ facade_key_secrets={},
+ )
+
+ assert registry.get_config("project-a").rate_limit is None
+ assert registry.to_dict()["project-a"]["rate_limit"] is None
+
+
+@pytest.mark.parametrize(
+ "rate_limit",
+ [
+ {"windows": [{"requests": 5, "seconds": 1}]},
+ {"windows": [{"requests": 5, "seconds": 1}, {"requests": 30}]},
+ {"requests": 5, "seconds": 1},
+ ],
+)
+def test_registry_rejects_malformed_or_partial_project_rate_limit(rate_limit):
+ with pytest.raises(ValueError, match=r"facade_keys\.project-a\.rate_limit"):
+ FacadeKeyRegistry.from_sources(
+ facade_key_configs={"project-a": {"rate_limit": rate_limit}},
+ facade_key_secrets={},
+ )
diff --git a/tests/unit/test_request_rate_limits.py b/tests/unit/test_request_rate_limits.py
new file mode 100644
index 0000000..7d12aa9
--- /dev/null
+++ b/tests/unit/test_request_rate_limits.py
@@ -0,0 +1,452 @@
+import asyncio
+import os
+from types import SimpleNamespace
+import uuid
+
+import fakeredis.aioredis
+import pytest
+import pytest_asyncio
+import redis.asyncio as redis
+
+from smolrouter.request_rate_limits import (
+ DEFAULT_REQUEST_RATE_LIMIT_POLICY,
+ RateLimitWindow,
+ RedisRequestRateLimiter,
+ RequestRateLimitConfig,
+ RequestRateLimitConfigError,
+ RequestRateLimitPolicy,
+ parse_request_rate_limit_config,
+ parse_request_rate_limit_policy,
+ request_rate_limit_bucket_key,
+)
+
+
+@pytest_asyncio.fixture
+async def fake_redis():
+ client = fakeredis.aioredis.FakeRedis(decode_responses=True)
+ try:
+ yield client
+ finally:
+ await client.aclose()
+
+
+def policy(short_requests=1, short_seconds=1, long_requests=3, long_seconds=10):
+ return RequestRateLimitPolicy(
+ windows=(
+ RateLimitWindow(short_requests, short_seconds),
+ RateLimitWindow(long_requests, long_seconds),
+ )
+ )
+
+
+def test_default_configuration_is_enabled_with_safe_two_window_limits():
+ expected = RequestRateLimitConfig(
+ enabled=True,
+ anonymous=DEFAULT_REQUEST_RATE_LIMIT_POLICY,
+ project_default=DEFAULT_REQUEST_RATE_LIMIT_POLICY,
+ )
+
+ assert parse_request_rate_limit_config(None) == expected
+ assert parse_request_rate_limit_config({}) == expected
+ assert DEFAULT_REQUEST_RATE_LIMIT_POLICY.to_dict() == {
+ "windows": [{"requests": 1, "seconds": 1}, {"requests": 3, "seconds": 10}]
+ }
+
+
+def test_complete_configuration_parses_without_coercion():
+ parsed = parse_request_rate_limit_config(
+ {
+ "enabled": False,
+ "anonymous": {"windows": [{"requests": 2, "seconds": 3}, {"requests": 8, "seconds": 20}]},
+ "project_default": {"windows": [{"requests": 10, "seconds": 1}, {"requests": 100, "seconds": 60}]},
+ }
+ )
+
+ assert parsed.enabled is False
+ assert parsed.anonymous.windows == (RateLimitWindow(2, 3), RateLimitWindow(8, 20))
+ assert parsed.project_default.windows == (RateLimitWindow(10, 1), RateLimitWindow(100, 60))
+
+
+@pytest.mark.parametrize(
+ ("raw", "error"),
+ [
+ ([], "must be a mapping"),
+ ({"enabled": 1}, "must be a boolean"),
+ ({"enabled": "true"}, "must be a boolean"),
+ ({"surprise": True}, "unknown keys"),
+ ({"anonymous": None}, "must be a mapping"),
+ ({"project_default": None}, "must be a mapping"),
+ ({"anonymous": []}, "must be a mapping"),
+ ({"anonymous": {}}, "windows is required"),
+ ({"anonymous": {"windows": ()}}, "must be a list"),
+ ({"anonymous": {"windows": []}}, "exactly 2"),
+ (
+ {"anonymous": {"windows": [{"requests": 1, "seconds": 1}, {"requests": 2, "seconds": 2}], "x": 1}},
+ "unknown keys",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 1, "seconds": 1, "x": 2}, {"requests": 2, "seconds": 2}]}},
+ "unknown keys",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 1}, {"requests": 2, "seconds": 2}]}},
+ "missing required keys",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": True, "seconds": 1}, {"requests": 2, "seconds": 2}]}},
+ "positive integer",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 1, "seconds": 1.0}, {"requests": 2, "seconds": 2}]}},
+ "positive integer",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 0, "seconds": 1}, {"requests": 2, "seconds": 2}]}},
+ "positive integer",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 1, "seconds": 2}, {"requests": 2, "seconds": 2}]}},
+ "strictly ascending",
+ ),
+ (
+ {"anonymous": {"windows": [{"requests": 3, "seconds": 1}, {"requests": 2, "seconds": 2}]}},
+ "monotonic",
+ ),
+ ],
+)
+def test_configuration_schema_is_strict(raw, error):
+ with pytest.raises(ValueError, match=error):
+ parse_request_rate_limit_config(raw)
+
+
+def test_policy_none_only_uses_an_explicit_default():
+ with pytest.raises(RequestRateLimitConfigError, match="must be a mapping"):
+ parse_request_rate_limit_policy(None, "project.rate_limit")
+
+ assert (
+ parse_request_rate_limit_policy(None, "project.rate_limit", DEFAULT_REQUEST_RATE_LIMIT_POLICY)
+ is DEFAULT_REQUEST_RATE_LIMIT_POLICY
+ )
+
+
+def test_config_errors_have_a_dedicated_fail_fast_type():
+ with pytest.raises(RequestRateLimitConfigError, match="enabled"):
+ parse_request_rate_limit_config({"enabled": "yes"})
+
+
+def test_bucket_keys_are_secret_free_stable_and_isolated():
+ anonymous_key = request_rate_limit_bucket_key("anonymous")
+ alpha_key = request_rate_limit_bucket_key(
+ "project", identity_kind="facade_key", identity_subject_id="sk-secret-alpha"
+ )
+ alpha_again = request_rate_limit_bucket_key(
+ "project", identity_kind="facade_key", identity_subject_id="sk-secret-alpha"
+ )
+ beta_key = request_rate_limit_bucket_key(
+ "project", identity_kind="facade_key", identity_subject_id="sk-secret-beta"
+ )
+
+ assert anonymous_key != alpha_key
+ assert alpha_key == alpha_again
+ assert alpha_key != beta_key
+ assert "facade_key" not in alpha_key
+ assert "sk-secret-alpha" not in alpha_key
+ assert len(alpha_key.rsplit(":", 1)[-1]) == 64
+
+
+@pytest.mark.parametrize(
+ ("bucket_class", "kind", "subject"),
+ [
+ ("other", None, None),
+ ("anonymous", "kind", None),
+ ("project", None, "subject"),
+ ("project", "", "subject"),
+ ("project", "kind", ""),
+ ],
+)
+def test_bucket_identity_validation(bucket_class, kind, subject):
+ with pytest.raises(ValueError):
+ request_rate_limit_bucket_key(
+ bucket_class,
+ identity_kind=kind,
+ identity_subject_id=subject,
+ )
+
+
+@pytest.mark.asyncio
+async def test_real_lua_script_enforces_exclusive_window_boundaries(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(1, 1, 3, 10)
+
+ first = await limiter._check_at_ms("anonymous", limits, 1_000)
+ denied = await limiter._check_at_ms("anonymous", limits, 1_999)
+ boundary = await limiter._check_at_ms("anonymous", limits, 2_000)
+
+ assert first.allowed is True
+ assert denied.allowed is False
+ assert denied.retry_after_ms == 1
+ assert denied.retry_after_seconds == 1
+ assert denied.violated_windows == (limits.windows[0],)
+ assert boundary.allowed is True
+ assert boundary.counts == (1, 2)
+
+
+@pytest.mark.asyncio
+async def test_long_window_boundary_is_exclusive(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(2, 1, 3, 10)
+ for timestamp in (1_000, 2_000, 3_000):
+ assert (await limiter._check_at_ms("anonymous", limits, timestamp)).allowed
+
+ denied = await limiter._check_at_ms("anonymous", limits, 10_999)
+ boundary = await limiter._check_at_ms("anonymous", limits, 11_000)
+
+ assert denied.allowed is False
+ assert denied.retry_after_ms == 1
+ assert denied.violated_windows == (limits.windows[1],)
+ assert boundary.allowed is True
+
+
+@pytest.mark.asyncio
+async def test_concurrent_checks_never_over_admit(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(3, 1, 3, 10)
+
+ decisions = await asyncio.gather(*(limiter._check_at_ms("anonymous", limits, 5_000) for _ in range(30)))
+
+ assert sum(decision.allowed for decision in decisions) == 3
+ bucket = request_rate_limit_bucket_key("anonymous")
+ assert await fake_redis.zcard(bucket) == 3
+
+
+@pytest.mark.asyncio
+async def test_denial_does_not_charge_or_change_bucket(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(1, 1, 3, 10)
+ await limiter._check_at_ms("anonymous", limits, 1_000)
+ bucket = request_rate_limit_bucket_key("anonymous")
+ before = await fake_redis.zrange(bucket, 0, -1, withscores=True)
+
+ for timestamp in (1_100, 1_200, 1_300):
+ assert not (await limiter._check_at_ms("anonymous", limits, timestamp)).allowed
+
+ assert await fake_redis.zrange(bucket, 0, -1, withscores=True) == before
+
+
+@pytest.mark.asyncio
+async def test_uuid_members_prevent_same_millisecond_collisions(fake_redis, monkeypatch):
+ ids = iter((SimpleNamespace(hex=f"request-{index}") for index in range(5)))
+ monkeypatch.setattr("smolrouter.request_rate_limits.uuid.uuid4", lambda: next(ids))
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(5, 1, 5, 10)
+
+ for _ in range(5):
+ assert (await limiter._check_at_ms("anonymous", limits, 1_000)).allowed
+
+ bucket = request_rate_limit_bucket_key("anonymous")
+ assert await fake_redis.zcard(bucket) == 5
+
+
+@pytest.mark.asyncio
+async def test_admission_prunes_cardinality_and_sets_max_window_ttl(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(2, 1, 3, 10)
+ bucket = request_rate_limit_bucket_key("anonymous")
+ for timestamp in (1_000, 2_000, 3_000):
+ assert (await limiter._check_at_ms("anonymous", limits, timestamp)).allowed
+
+ assert await fake_redis.zcard(bucket) == 3
+ assert 10_900 <= await fake_redis.pttl(bucket) <= 11_000
+
+ assert (await limiter._check_at_ms("anonymous", limits, 11_000)).allowed
+ assert await fake_redis.zcard(bucket) == 3
+
+
+@pytest.mark.asyncio
+async def test_lowered_policy_uses_c_minus_l_event_rank_for_retry(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ initial = policy(10, 1, 10, 10)
+ for timestamp in (100, 200, 300, 400, 500):
+ assert (await limiter._check_at_ms("anonymous", initial, timestamp)).allowed
+
+ lowered = policy(2, 1, 10, 10)
+ denied = await limiter._check_at_ms("anonymous", lowered, 600)
+
+ assert denied.allowed is False
+ assert denied.counts == (5, 5)
+ assert denied.retry_after_ms == 800 # rank 5 - 2 => event at 400, expiring at 1400
+ assert denied.retry_after_seconds == 1
+
+
+@pytest.mark.asyncio
+async def test_retry_is_maximum_wait_across_all_violated_windows(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ initial = policy(10, 1, 10, 10)
+ for timestamp in (100, 200, 300, 400, 500):
+ assert (await limiter._check_at_ms("anonymous", initial, timestamp)).allowed
+
+ lowered = policy(2, 1, 3, 10)
+ denied = await limiter._check_at_ms("anonymous", lowered, 600)
+
+ assert denied.violated_windows == lowered.windows
+ assert denied.retry_after_ms == 9_700 # long rank 5 - 3 => event at 300
+ assert denied.retry_after_seconds == 10
+
+
+@pytest.mark.asyncio
+async def test_project_buckets_are_isolated(fake_redis):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(1, 1, 3, 10)
+
+ alpha = await limiter._check_at_ms("project", limits, 1_000, "facade_key", "alpha")
+ alpha_denied = await limiter._check_at_ms("project", limits, 1_000, "facade_key", "alpha")
+ beta = await limiter._check_at_ms("project", limits, 1_000, "facade_key", "beta")
+
+ assert alpha.allowed is True
+ assert alpha_denied.allowed is False
+ assert beta.allowed is True
+
+
+@pytest.mark.asyncio
+async def test_stats_use_only_bounded_fields_and_are_best_effort(fake_redis, monkeypatch, caplog):
+ limiter = RedisRequestRateLimiter(fake_redis)
+ limits = policy(1, 1, 3, 10)
+ await limiter._check_at_ms("anonymous", limits, 1_000)
+ await limiter._check_at_ms("anonymous", limits, 1_000)
+ await limiter._check_at_ms("project", limits, 1_000, "kind", "one")
+ await limiter._check_at_ms("project", limits, 1_000, "kind", "one")
+
+ stats = await fake_redis.hgetall("smolrouter:request-rate-limit:stats")
+ assert stats == {
+ "admitted:anonymous": "1",
+ "rejected:anonymous": "1",
+ "admitted:project": "1",
+ "rejected:project": "1",
+ }
+
+ async def broken_stats(*_args, **_kwargs):
+ raise RuntimeError("telemetry unavailable")
+
+ monkeypatch.setattr(fake_redis, "hincrby", broken_stats)
+ with caplog.at_level("DEBUG", logger="smolrouter.request_rate_limits"):
+ assert (await limiter._check_at_ms("anonymous", limits, 2_000)).allowed
+ assert "aggregate stats update failed" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_backend_failure_is_counted_and_raised_without_logging_credentials(fake_redis, monkeypatch, caplog):
+ limiter = RedisRequestRateLimiter(fake_redis)
+
+ async def broken_eval(*_args, **_kwargs):
+ raise RuntimeError("redis unavailable for credential sk-must-not-leak")
+
+ monkeypatch.setattr(fake_redis, "eval", broken_eval)
+ assert limiter.backend_error_count == 0
+ assert limiter.stats == {"backend_error_count": 0}
+ with caplog.at_level("WARNING", logger="smolrouter.request_rate_limits"):
+ with pytest.raises(RuntimeError, match="redis unavailable"):
+ await limiter.check("anonymous", DEFAULT_REQUEST_RATE_LIMIT_POLICY)
+
+ assert limiter.backend_error_count == 1
+ assert limiter.stats == {"backend_error_count": 1}
+ assert "backend check failed" in caplog.text
+ assert "sk-must-not-leak" not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_verify_exercises_production_lua_and_cleans_up(fake_redis):
+ await RedisRequestRateLimiter(fake_redis).verify()
+ assert await fake_redis.keys("*") == []
+
+
+@pytest.mark.asyncio
+async def test_verify_cleanup_does_not_mask_primary_error(fake_redis, monkeypatch):
+ limiter = RedisRequestRateLimiter(fake_redis)
+
+ async def broken_eval(*_args, **_kwargs):
+ raise RuntimeError("primary verification failure")
+
+ async def broken_delete(*_args, **_kwargs):
+ raise ConnectionError("cleanup failure")
+
+ monkeypatch.setattr(fake_redis, "eval", broken_eval)
+ monkeypatch.setattr(fake_redis, "delete", broken_delete)
+
+ with pytest.raises(RuntimeError, match="primary verification failure"):
+ await limiter.verify()
+
+
+class _RealRedisWithoutAggregateStats:
+ """Delegate Redis semantics while keeping an optional shared test DB free of stats changes."""
+
+ def __init__(self, client):
+ self._client = client
+
+ def __getattr__(self, name):
+ return getattr(self._client, name)
+
+ async def hincrby(self, *_args, **_kwargs):
+ return 0
+
+
+@pytest.mark.asyncio
+async def test_semantic_suite_against_real_redis_when_explicitly_configured():
+ redis_url = os.getenv("SMOLROUTER_TEST_REDIS_URL")
+ if not redis_url:
+ pytest.skip("set SMOLROUTER_TEST_REDIS_URL to exercise a disposable real Redis")
+
+ raw_client = redis.from_url(redis_url, decode_responses=True)
+ client = _RealRedisWithoutAggregateStats(raw_client)
+ limiter = RedisRequestRateLimiter(client)
+ suite_id = uuid.uuid4().hex
+ identities = {
+ name: ("real_redis_test", f"{suite_id}:{name}")
+ for name in ("concurrency", "boundary", "lowered", "cardinality")
+ }
+ bucket_keys = [
+ request_rate_limit_bucket_key("project", identity_kind=kind, identity_subject_id=subject)
+ for kind, subject in identities.values()
+ ]
+ try:
+ await limiter.verify()
+
+ kind, subject = identities["concurrency"]
+ concurrency_policy = policy(3, 1, 3, 10)
+ decisions = await asyncio.gather(
+ *(limiter._check_at_ms("project", concurrency_policy, 5_000, kind, subject) for _ in range(30))
+ )
+ assert sum(decision.allowed for decision in decisions) == 3
+ concurrency_key = request_rate_limit_bucket_key("project", identity_kind=kind, identity_subject_id=subject)
+ before_deny = await raw_client.zrange(concurrency_key, 0, -1, withscores=True)
+ assert not (await limiter._check_at_ms("project", concurrency_policy, 5_001, kind, subject)).allowed
+ assert await raw_client.zrange(concurrency_key, 0, -1, withscores=True) == before_deny
+
+ kind, subject = identities["boundary"]
+ boundary_policy = policy(1, 1, 3, 10)
+ assert (await limiter._check_at_ms("project", boundary_policy, 1_000, kind, subject)).allowed
+ just_inside = await limiter._check_at_ms("project", boundary_policy, 1_999, kind, subject)
+ at_boundary = await limiter._check_at_ms("project", boundary_policy, 2_000, kind, subject)
+ assert not just_inside.allowed and just_inside.retry_after_ms == 1
+ assert at_boundary.allowed
+
+ kind, subject = identities["lowered"]
+ initial = policy(10, 1, 10, 10)
+ for timestamp in (100, 200, 300, 400, 500):
+ assert (await limiter._check_at_ms("project", initial, timestamp, kind, subject)).allowed
+ lowered = await limiter._check_at_ms("project", policy(2, 1, 10, 10), 600, kind, subject)
+ assert not lowered.allowed
+ assert lowered.counts == (5, 5)
+ assert lowered.retry_after_ms == 800
+
+ kind, subject = identities["cardinality"]
+ cardinality_policy = policy(2, 1, 3, 10)
+ cardinality_key = request_rate_limit_bucket_key("project", identity_kind=kind, identity_subject_id=subject)
+ for timestamp in (1_000, 2_000, 3_000):
+ assert (await limiter._check_at_ms("project", cardinality_policy, timestamp, kind, subject)).allowed
+ assert await raw_client.zcard(cardinality_key) == 3
+ assert 10_900 <= await raw_client.pttl(cardinality_key) <= 11_000
+ assert (await limiter._check_at_ms("project", cardinality_policy, 11_000, kind, subject)).allowed
+ assert await raw_client.zcard(cardinality_key) == 3
+ finally:
+ await raw_client.delete(*bucket_keys)
+ await raw_client.aclose()
diff --git a/uv.lock b/uv.lock
index 1e329fb..59f605a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -364,15 +364,20 @@ wheels = [
[[package]]
name = "fakeredis"
-version = "2.31.3"
+version = "2.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/96/1e/27170815a9768d2eaf72e66dfad38047b55ea278df84b539ad0045ca1538/fakeredis-2.31.3.tar.gz", hash = "sha256:76dfb92855f0787a4936a5b4fdb1905c5909ec790e62dff2b8896b412905deb0", size = 170984, upload-time = "2025-09-22T12:24:54.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/ed/86ed74d8829c3bc565025c1c9efaf8518c9165fbf8c9cc2c026c8ed21bd9/fakeredis-2.36.2.tar.gz", hash = "sha256:c37a0b307fae3f27ec7c19e59519e57b8c52782e00303df9075361b5ba441be6", size = 213336, upload-time = "2026-06-17T13:25:38.934Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/07/d6/7cad31e16b7d8343ed7abf5ddb039a063b32a300def1aa487d91b4a5c831/fakeredis-2.31.3-py3-none-any.whl", hash = "sha256:12aa54a3fb00984c18b28956addb91683aaf55b2dc2ef4b09d49bd481032e57a", size = 118398, upload-time = "2025-09-22T12:24:52.751Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/4d/e6e40f93031adbf654d34e542bd396e9f3bc1c6209b40c920d58c9e1317a/fakeredis-2.36.2-py3-none-any.whl", hash = "sha256:84cbb9c74ca8946c0d2499daadf3a5d0bfe3cfbac71e3398316d1a1eab3421c4", size = 141039, upload-time = "2026-06-17T13:25:36.638Z" },
+]
+
+[package.optional-dependencies]
+lua = [
+ { name = "lupa" },
]
[[package]]
@@ -707,6 +712,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/68/ee314018c28da75ece5a639898b4745bd0687c0487fc465811f0c4b9cd44/limits-5.5.0-py3-none-any.whl", hash = "sha256:57217d01ffa5114f7e233d1f5e5bdc6fe60c9b24ade387bf4d5e83c5cf929bae", size = 60948, upload-time = "2025-08-05T18:23:53.335Z" },
]
+[[package]]
+name = "lupa"
+version = "2.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/a6/0f869fbb07c393f15473b1eefefb7b5bec162fb7481803d040ed4dc46002/lupa-2.8.tar.gz", hash = "sha256:d8022641b9ec8ecf2c5ecbe9f47e5a70e0b87c4b5ae921b92cb02a638e0acd08", size = 6156370, upload-time = "2026-04-15T20:08:30.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/21/9be4516ddd22f8eadba336d9ba065d17d79108465ae1b7f71424ab99b9d0/lupa-2.8-cp310-abi3-win32.whl", hash = "sha256:c2a5fd15dc62374e1661a55f01744c9ec1c56f291ba4a0749d3af2174556e78f", size = 1594887, upload-time = "2026-04-15T20:05:23.377Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/99/1557c9685d7034d9ce8dd2b54c40a26d6deb7c67c1fdb5c801abd1a02c3f/lupa-2.8-cp310-abi3-win_arm64.whl", hash = "sha256:9e304fb1c50cf23fd8882afbe1aa87525ef8a72667bcab3b37b2bbb2bc542269", size = 1371742, upload-time = "2026-04-15T20:05:27.417Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/0a/5a740717f27aa77481e6a61b97cf79d1e0c1ede729b1268caacded915326/lupa-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b12e43c1fb787189dfc28cd604aef0baa2cb95e27da19498d520361d0ace070a", size = 1202376, upload-time = "2026-04-15T20:05:44.049Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/75/6b64d0098c64275a801896cb7a6a30e7e653d25fa102c64e747292afcdbb/lupa-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f603391dffb256e36a79fd2044084d5f4b8a0a4c0e5ad291cd3ab3aaf1fd0a", size = 1839271, upload-time = "2026-04-15T20:05:47.399Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2f/0d4f00563046ff616ef6a421f8b776a5ffb327f7b32ed69e856d52b917a8/lupa-2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f6f41c91366e7d0d474f87d81c1274af861f40812bf729c9f97ab4c8f3c7ac8", size = 2376251, upload-time = "2026-04-15T20:05:49.891Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/8e/caa83237f427d9e85b7f02c816e7270c9c9571dec1673e06b0180402f70e/lupa-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:f5a6af145b0ea818f01d27bfe2583a4b538570bef61d22c8773e0eccf011234c", size = 1923488, upload-time = "2026-04-15T20:05:52.954Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0b/368f2f0bc750b25c69d4563e44f677925ab5dd3d2887f9b0c15465d21a2a/lupa-2.8-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:f4342f4de76ae7ce2ab0672d36003bdb7e1a33252f293b569298ddd792e70e33", size = 1194056, upload-time = "2026-04-15T20:05:55.794Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/0f/c89eb8dd36fdea4e50ae3f7f5275bea3b0cc5d4057b8ee7b3bbc78010422/lupa-2.8-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:4203fa1659315e939a5304e75001b8cc14234fb3cbb3ed86c049b0cc5d90fcee", size = 1434278, upload-time = "2026-04-15T20:05:57.94Z" },
+ { url = "https://files.pythonhosted.org/packages/47/30/c3b4d2cd8733621b404b8a4214e5f852955c4ba632546dc84123bea9ee89/lupa-2.8-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:81f2d843ce668b653146c007467570210ae44be51dac6926666c51d49536f307", size = 1150068, upload-time = "2026-04-15T20:06:01.04Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d2/bac12c398519efafc6af84be1974edd0d7a4895fb4735b5c8d615d298595/lupa-2.8-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3d0cde2c77588d1c60875a4f34f059513476c6e1775351897195b51e0f3df08", size = 1409532, upload-time = "2026-04-15T20:06:03.592Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6a/18b52e11962014026e07813530b0b108ee8bc0a2a13ef0eaea5d41dce023/lupa-2.8-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e0d11b8f3a8dac6413f704fef7161d048bb10c58bdac6cbffa5e60efa56e9a3", size = 1242687, upload-time = "2026-04-15T20:06:06.863Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/8e/7fd4eb049875f61429b96780d2eae4700f0e78fe0a52db8edb231b1cd09f/lupa-2.8-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54cff414f21f8cd8c6be4aae52541f3b9cd39602b59e3a3db9b5c9f9f674ff18", size = 1856038, upload-time = "2026-04-15T20:06:09.358Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/f9/37ad9d2773d30f2931890d310a4bdce28d45484206e6f48bc18b0325eabd/lupa-2.8-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24b4d8af5558e549b70daf1547f5c1c1d664ecea9fc790f83efe5d75e9a93797", size = 1128982, upload-time = "2026-04-15T20:06:12.312Z" },
+ { url = "https://files.pythonhosted.org/packages/57/31/c0fd7984c24844ea79caa45c0235f61a06b38fd69a839f6c62770f8d684a/lupa-2.8-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce86dff1ee7f7cf45f5622065ae991949dd7bb1703581cbc58a630137bb7ccf9", size = 1457594, upload-time = "2026-04-15T20:06:15.881Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f5/a28e411be30ec1bf0db1eb0c087eebc73be9e7a1adcfe6ac209861ccc446/lupa-2.8-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f4d01b2a08c70bbb883a9e082b6b36b89121ed5910b710f1ba11c73295ff4fba", size = 1425721, upload-time = "2026-04-15T20:06:18.009Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/c1/359f767c4ae024be30d909fe8a9f0e9af266bad47ce2bd2ed248fb986fcf/lupa-2.8-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:7f210d5a8353e510ea1199c42cf3cbdd630553bf2bc8fb4c00fea06fdec7c798", size = 1253258, upload-time = "2026-04-15T20:06:21.17Z" },
+ { url = "https://files.pythonhosted.org/packages/17/52/473f11790c261fd02bbf318a546fe040e9ec9f677181272fa78d3b4112a4/lupa-2.8-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4f81a02806e7c7ad26d8c6fa222c8bef1b0c1b124347c879be880b41339d41e4", size = 2395272, upload-time = "2026-04-15T20:06:24.137Z" },
+ { url = "https://files.pythonhosted.org/packages/94/bf/75c8795655a8836eab6a11a630352c4b7c5dc5c54d075077bc9bffdeee45/lupa-2.8-cp312-abi3-win32.whl", hash = "sha256:360056453a7a4eaa4ac5a204c31a5a014b1eb2ee5490603234d2ba831684f1f2", size = 1606136, upload-time = "2026-04-15T20:06:27.815Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/29/11a2cdd612b6f55e506292dfb6ba343216e80a693e7fe3f876ef204ce9c6/lupa-2.8-cp312-abi3-win_arm64.whl", hash = "sha256:1628371c6592a6d5650497a9e31fb2bb3a7e9883c1f301d1111265e484045af9", size = 1364495, upload-time = "2026-04-15T20:06:30.254Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/17/fa834b6b09ad17e7df5d0f7715d64877a125a3776ada689751a1f9dc2959/lupa-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:450650f91c48c2415b0d59ab3abfcfda3b6efb5b858205f4d4bda8ad141fa529", size = 1190111, upload-time = "2026-04-15T20:06:32.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/43/45589901b7d1a0e3a9d91d19a311fb6a56924e8571536c3f2212160fd953/lupa-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27044f3363047f946b3d3aab9157cbd172b3538ada9ec1baef43432bf7d03a78", size = 1812999, upload-time = "2026-04-15T20:06:35.664Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ac/4ade7d15ff5c61758d7943ac6f0a496bf1cc65b6c09f842b52a0702e664c/lupa-2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cf4f064a0e5531afce2d7d750120c10c10f9529139af6ca6150d13151034398", size = 2368731, upload-time = "2026-04-15T20:06:37.959Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/27/05f950d15b8ab120b39c43588b438ff3ace70c1b1b0225a960393a497483/lupa-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:281bedc5deb92d31e649a3552edd662449365a635904fa4d5cb4509c7245e34e", size = 1941809, upload-time = "2026-04-15T20:06:40.302Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/3f/19f83c3a0c84dc8bea8a58e7416dca6a3ede662c33c8d1ec758e5afc754a/lupa-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fc9da0145ecb0083ef5ff9975116cc784bd0258bdc2bd131ba15483ce18398", size = 1201203, upload-time = "2026-04-15T20:06:42.169Z" },
+ { url = "https://files.pythonhosted.org/packages/89/0f/a14f0073f09610158038582e230618a48c14da6bd88185289461aa4cb854/lupa-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e18afed57955b41130e269c78f53d4123ab86e236b53816f4cbffa25cb5d30", size = 1806210, upload-time = "2026-04-15T20:06:45.486Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/14/48fff156c63a136001a7620878af7d31aa07e66b495ed621e3eddd73c294/lupa-2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc47f536ac13a79cef47d29a2b205576a22841f042a2bcec1676b95806e7706a", size = 2359005, upload-time = "2026-04-15T20:06:47.819Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/18/3ac638ec90edf178242b8a2b2f00f8adae694248c03a26341ef941bb746e/lupa-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:ce9404c661dbac65cc9bed351ad45e797af93d30d70be309a3fa8209ac86d93b", size = 1936754, upload-time = "2026-04-15T20:06:50.448Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ef/5ee5fed6ea7459a671196359ce04bfeeaf26be1dac8ff24bf28e5c7a6e81/lupa-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348c3f8ecabb6324dcbc05c2740d762ef8fcec7b06c79e45262ab97a217684e3", size = 1209388, upload-time = "2026-04-15T20:06:53.022Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/b1/67a940d5542cb0384b443fe951b5a83ea9340d1333a733a258fdd1c619ba/lupa-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951496471056061598a7d1729a6cdf48d662fec777a9f2d8aa5a1e62fd30e5a5", size = 1826821, upload-time = "2026-04-15T20:06:55.699Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a2/b354e5ba3b911ec50686003dc8897e892b9e8c5c036b33219b03d54c4daf/lupa-2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a591b9947ca347b41a63370e121d6e2b1458fe6dde9ae065029ec10a37f25ff4", size = 2366893, upload-time = "2026-04-15T20:06:58.9Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/52/d76066401f29539df5352f70ecded66576f32933b6045cd0bfc56cb770b9/lupa-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:3903c9cf628dae2f56405503247b77a61a3a61bd2dda470e336950c74776d55d", size = 1994716, upload-time = "2026-04-15T20:07:19.194Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/bd/3efc437a4361c16d25e66478c50357c9a8e8ecfb718fe749eb9ca3176ef6/lupa-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f711a8ab0486b9ac6fdda94a22ddcfbc9f0d4a27e3a8cf1bf79c6e48b33017c1", size = 1251217, upload-time = "2026-04-15T20:07:01.64Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f4/2e9f8ecbaca854bfdf14af8a9b505ec0cbc640377b3b218921594b7563cd/lupa-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc51250e76367a3e27fcd01dc769b9bfcbbc34f48df48dde53d6af6e75b7eaa5", size = 1814701, upload-time = "2026-04-15T20:07:04.149Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/53/4000b1acaa8b1f3827fcff0cfcdff44d3befddda42cab7e685a49689b5a1/lupa-2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8a22088a552828958603323f0a5c4b3e11e03b75d0bf4c965ef879de9b60a8d", size = 2348414, upload-time = "2026-04-15T20:07:07.285Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/78/26ee48d3890cddf03cefb65f433e3492759c0b3c0582180755bddbaab7bd/lupa-2.8-cp314-cp314t-win32.whl", hash = "sha256:4f7c553c1d8cfffbe85d81daef730d12cae4b6002d457542914da0ac8a1145b3", size = 1831611, upload-time = "2026-04-15T20:07:09.752Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d1/4a5cc64a3cad22821ae4c3f7a90456a08ca19457d8354f4abf46ad03c7e8/lupa-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:d8766aff03a78c80ad2d188a8bdb216de5ec838359cd87e05bbdfa56394a6105", size = 2209250, upload-time = "2026-04-15T20:07:11.906Z" },
+ { url = "https://files.pythonhosted.org/packages/37/7c/cdcb654daf668192aaf36b0aeb94f2281dad092aaa5003688691131736ea/lupa-2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:91d622777febda3ab1bed1d45295f2f32a4680c7b3d7caf8c669998ed5c44118", size = 1126735, upload-time = "2026-04-15T20:07:15.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/44/de1961ad38e17cd326a53c246c7e3b91178ed578f4cf22ffcd5e7e11b041/lupa-2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b036738282a5acd2e71fdddb317c9df8b87c1673aa57f403d05fcc2be8abc4ba", size = 1186020, upload-time = "2026-04-15T20:07:35.017Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c2/276f0b9dc8bcc5a8a58af5316dfa0e6f56be3613dd6dbcc8d3d2cb6559ba/lupa-2.8-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ac6b6e8d0e617e26a98cbb44880bcd75de5d32b3ad7b3b3793583909292b47ed", size = 1468944, upload-time = "2026-04-15T20:07:37.782Z" },
+ { url = "https://files.pythonhosted.org/packages/63/38/52934e52a5180dc6425d20284d004fe4b27a4f9171a82dc99fb67af250bf/lupa-2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba3a7dd839f90c3d2e53bebe3c192b1f3f9fd720a6781256405123211fd0dce6", size = 1172998, upload-time = "2026-04-15T20:07:40.812Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/82/76b3809bd0839d9b3b4ec58d06591e08f17337b6d9576877cb9d48b34e94/lupa-2.8-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7edb13a7a5250b5c6c22d1495d9e842b5c9fc5081c8fe6b5efe2112fe3e41f9", size = 1449975, upload-time = "2026-04-15T20:07:44.262Z" },
+ { url = "https://files.pythonhosted.org/packages/16/07/2f89d54f747c67c23b4b9ae4aa8c8dd06bb409155dedcf406157f2736b66/lupa-2.8-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:891f72e0bffbed1e4175f975aeb2a083956586a100066525e1be485f617f7b25", size = 1281944, upload-time = "2026-04-15T20:07:46.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bd/7375d2b0fcae79d806baf52a76f26c96964593f58e1372d13ae5ac09c676/lupa-2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a295f87b5b7ebbfd5191932e8cb0e51df3c7769101ac6b6c7d7c9fb27bfd1307", size = 1910455, upload-time = "2026-04-15T20:07:49.75Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/0c/8abb3bc0e08b311fc01db05b6e9f9ff31a8f65e4fc3f0aeb05cfef75c8ac/lupa-2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4fe5d7a810b64ea8511eb885fc8cdde042ee5ff7b7d08ae78f32449756acb177", size = 1155548, upload-time = "2026-04-15T20:07:52.657Z" },
+ { url = "https://files.pythonhosted.org/packages/80/2e/9eeecd3f493099721c1d3f31beeca23a4237db1a54223684df4dc96aa1bd/lupa-2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfc470012ef66ad064c7bd77416af03a3452ef630b04b9012595ea13f2e54518", size = 1489232, upload-time = "2026-04-15T20:07:54.92Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/13/731c99dc2e7652ae818a6de45bdf0142049f7cb566049061c898355f1891/lupa-2.8-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:250e035fdaffe8c87093e3ebc206ac29a26131b1568ea711d780c26001ce96e7", size = 1466321, upload-time = "2026-04-15T20:07:57.627Z" },
+ { url = "https://files.pythonhosted.org/packages/de/71/3ad8cc4fc05a77dc0d3f7079348bd1cad4675a0d14c24f8e6a3ce5f008f7/lupa-2.8-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b9bddb09acfffb4f828f790f444b11dc0cca591afea1a244d9329eea2d20c003", size = 1288577, upload-time = "2026-04-15T20:07:59.913Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/b2/1175f6d0aa7b68627fbe2f58bd1e8bea36a89d10dfd67671d2b024c96162/lupa-2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e64acbbd47e9b82a64405a39e0d2b36a5a7dad8ab41c0f3437f572f7d282ba3", size = 2444866, upload-time = "2026-04-15T20:08:02.753Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/e78df680c7a0ea452daac07467ca188d63c2c00ca1c884c0a50e27eb83b5/lupa-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e4e5103bbddcdd2458fb2ccae6c8ba11c9997c711d7e379e0d45551d109c76", size = 1778509, upload-time = "2026-04-15T20:08:21.784Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/23/0e53cabb16b2a8aa9cf1fde499c097d8942c5dab709fc8e921f3b824b18b/lupa-2.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7667001804657496dee9feced2daae5000b4604a3218dd8e6b7b754982ba88b8", size = 2300480, upload-time = "2026-04-15T20:08:24.394Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.2"
@@ -1244,14 +1308,14 @@ wheels = [
[[package]]
name = "redis"
-version = "6.4.0"
+version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" },
]
[[package]]
@@ -1337,7 +1401,7 @@ version = "2.0.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
- { name = "fakeredis" },
+ { name = "fakeredis", extra = ["lua"] },
{ name = "fastapi" },
{ name = "google-api-core" },
{ name = "google-genai" },
@@ -1380,7 +1444,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.12.15" },
- { name = "fakeredis", specifier = ">=2.31.3" },
+ { name = "fakeredis", extras = ["lua"], specifier = ">=2.31.3" },
{ name = "fastapi" },
{ name = "flake8", marker = "extra == 'dev'" },
{ name = "flake8-bugbear", marker = "extra == 'dev'" },