2b20946ad7
QA review of PR #39 (CAR-1121) identified three blocking issues; this commit addresses all three plus the typecheck errors flagged as CI RED. CAR-1077 (PR #39) changes: - database.py: add pool_timeout=30 so the engine fails fast when the connection pool is exhausted (defends against the "server closed connection unexpectedly" pod failures). - routes/health.py: /health now calls SELECT 1 through Depends(get_db) and raises HTTPException(503) when the database is unreachable, so Kubernetes readiness probes can correctly mark the pod unhealthy and stop routing traffic to it. Logs the failure at exception level for observability. - Drop .mcp.json from this PR (root-level MCP server config, not related to the pool fix; tracked separately). CI typecheck fixes (pre-existing on dev, were failing mypy on PR #39): - auth/passwords.py: cast bcrypt return values so mypy doesn't widen to Any. - config.py: silence the false-positive call-arg on Settings() — the three required fields are populated from the environment by pydantic-settings at runtime. - cache.py: coerce the bytes/str union returned by the redis client to the documented str | None return type. - middleware/rate_limit.py: annotate the three module-level limiters with the RateLimitBackend protocol, cast the redis zrange score to float before arithmetic, and add max_requests/window_seconds to the protocol so the response-header builder can read them. Co-Authored-By: Paperclip <noreply@paperclip.ing>
82 lines
2.5 KiB
Python
82 lines
2.5 KiB
Python
"""Redis/DragonflyDB caching helpers."""
|
|
|
|
import redis.asyncio as redis
|
|
|
|
from cartsnitch_api.config import settings
|
|
|
|
|
|
class CacheClient:
|
|
"""Redis/DragonflyDB caching with connection pooling.
|
|
|
|
Will be used for expensive queries: price trends, product comparisons.
|
|
Cache invalidation via Redis pub/sub events from other services.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._pool: redis.ConnectionPool | None = None
|
|
self._client: redis.Redis | None = None
|
|
|
|
async def initialize(self) -> None:
|
|
"""Initialize the Redis connection pool."""
|
|
self._pool = redis.ConnectionPool.from_url(
|
|
settings.redis_url,
|
|
max_connections=20,
|
|
decode_responses=True,
|
|
)
|
|
self._client = redis.Redis(connection_pool=self._pool)
|
|
|
|
async def close(self) -> None:
|
|
"""Close the Redis connection pool."""
|
|
if self._client:
|
|
await self._client.aclose()
|
|
if self._pool:
|
|
await self._pool.aclose()
|
|
|
|
async def get(self, key: str) -> str | None:
|
|
if not self._client:
|
|
return None
|
|
value = await self._client.get(key)
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return value
|
|
|
|
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
|
if not self._client:
|
|
return
|
|
await self._client.set(key, value, ex=ttl_seconds)
|
|
|
|
async def delete(self, key: str) -> None:
|
|
if not self._client:
|
|
return
|
|
await self._client.delete(key)
|
|
|
|
async def invalidate_price_cache(self, product_id: str) -> None:
|
|
"""Invalidate all price-related cache entries for a product."""
|
|
if not self._client:
|
|
return
|
|
pattern = f"price:*:{product_id}"
|
|
await self._delete_pattern(pattern)
|
|
|
|
async def invalidate_product_cache(self, product_id: str) -> None:
|
|
"""Invalidate the product detail cache entry."""
|
|
if not self._client:
|
|
return
|
|
await self._client.delete(f"product:{product_id}")
|
|
|
|
async def _delete_pattern(self, pattern: str) -> None:
|
|
"""Delete all keys matching a pattern using SCAN."""
|
|
if not self._client:
|
|
return
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100)
|
|
if keys:
|
|
await self._client.delete(*keys)
|
|
if cursor == 0:
|
|
break
|
|
|
|
|
|
cache_client = CacheClient()
|