Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions app/adapters/blizzard/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Blizzard HTTP client adapter implementing BlizzardClientPort"""

import math
import time

import httpx
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down