diff --git a/app/adapters/blizzard/client.py b/app/adapters/blizzard/client.py index e0574e3..41c3faa 100644 --- a/app/adapters/blizzard/client.py +++ b/app/adapters/blizzard/client.py @@ -1,5 +1,6 @@ """Blizzard HTTP client adapter implementing BlizzardClientPort""" +import math import time import httpx @@ -28,6 +29,7 @@ class BlizzardClient(metaclass=Singleton): def __init__(self): self.cache_manager = CacheManager() + self._rate_limited_until: float = 0 self.client = httpx.AsyncClient( headers={ "User-Agent": ( @@ -129,14 +131,26 @@ async def _check_rate_limit(self) -> None: Returns HTTP 429 with Retry-After header if rate limited. + Checks both Valkey (shared across workers) and an in-memory timestamp + (fallback when Valkey is unavailable). + Note: Nginx also performs this check on API cache miss for better performance, but this method remains necessary for: - Race conditions (concurrent requests when rate limit is first set) - Defense in depth (if nginx check fails or is bypassed) """ + # Check in-memory fallback first (works even when Valkey is down) + remaining = self._rate_limited_until - time.monotonic() + if remaining > 0: + raise self._too_many_requests_response(retry_after=math.ceil(remaining)) + if await self.cache_manager.is_being_rate_limited(): + remaining_ttl = await self.cache_manager.get_global_rate_limit_remaining_time() + # Sync in-memory fallback with Valkey TTL so it can protect + # if Valkey becomes unavailable mid-rate-limit window + self._rate_limited_until = time.monotonic() + float(remaining_ttl) raise self._too_many_requests_response( - retry_after=await self.cache_manager.get_global_rate_limit_remaining_time() + retry_after=math.ceil(float(remaining_ttl)) ) def blizzard_response_error_from_response( @@ -164,7 +178,10 @@ async def _blizzard_forbidden_error(self) -> HTTPException: Also prevent further calls to Blizzard for a given amount of time. """ - # We have to block future requests to Blizzard, cache the information on Valkey + # Block future requests: store in Valkey (shared) and in-memory (fallback) + self._rate_limited_until = ( + time.monotonic() + settings.blizzard_rate_limit_retry_after + ) await self.cache_manager.set_global_rate_limit() # Track rate limit event diff --git a/tests/conftest.py b/tests/conftest.py index c73e937..49638ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import pytest_asyncio from fastapi.testclient import TestClient +from app.adapters.blizzard import BlizzardClient from app.adapters.storage import SQLiteStorage from app.api.dependencies import get_storage from app.main import app @@ -46,6 +47,8 @@ async def _patch_before_every_test( await valkey_server.flushdb() await storage_db.clear_all_data() + # Reset in-memory rate limit state on the singleton + BlizzardClient()._rate_limited_until = 0 app.dependency_overrides[get_storage] = lambda: storage_db with (