60 lines
2.1 KiB
Python
60 lines
2.1 KiB
Python
"""Redis fixed-window counter with fail-open degradation."""
|
|
|
|
import logging
|
|
from functools import lru_cache
|
|
from typing import Any, Protocol
|
|
|
|
from app.core.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CounterBackend(Protocol):
|
|
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None: ...
|
|
|
|
|
|
class RedisCounterBackend:
|
|
def __init__(self, redis_url: str, connect_timeout_seconds: float) -> None:
|
|
self._redis_url = redis_url
|
|
self._connect_timeout_seconds = connect_timeout_seconds
|
|
self._client: Any | None = None
|
|
|
|
async def _client_or_none(self) -> Any | None:
|
|
if self._client is not None:
|
|
return self._client
|
|
try:
|
|
from redis.asyncio import Redis
|
|
|
|
self._client = Redis.from_url(
|
|
self._redis_url, socket_connect_timeout=self._connect_timeout_seconds,
|
|
socket_timeout=self._connect_timeout_seconds, decode_responses=True,
|
|
)
|
|
except Exception:
|
|
logger.warning("rate limit backend unavailable during client creation", exc_info=True)
|
|
return None
|
|
return self._client
|
|
|
|
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
|
|
client = await self._client_or_none()
|
|
if client is None:
|
|
return None
|
|
try:
|
|
async with client.pipeline(transaction=False) as pipeline:
|
|
pipeline.incr(key)
|
|
pipeline.ttl(key)
|
|
count, ttl = await pipeline.execute()
|
|
remaining = int(ttl)
|
|
if remaining < 0:
|
|
await client.expire(key, window_seconds)
|
|
remaining = window_seconds
|
|
return int(count), max(1, remaining)
|
|
except Exception:
|
|
logger.warning("rate limit backend unavailable; allowing request", exc_info=True)
|
|
return None
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def default_counter_backend() -> CounterBackend:
|
|
settings = get_settings()
|
|
return RedisCounterBackend(settings.redis_url, settings.redis_connect_timeout_seconds)
|