Files

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)