Skip to content

Comments

fix(client): add in-memory rate limit fallback when Valkey is unavailable#372

Merged
TeKrop merged 4 commits intoTeKrop:mainfrom
danielsogl:fix/rate-limit-fallback-when-valkey-down
Feb 22, 2026
Merged

fix(client): add in-memory rate limit fallback when Valkey is unavailable#372
TeKrop merged 4 commits intoTeKrop:mainfrom
danielsogl:fix/rate-limit-fallback-when-valkey-down

Conversation

@danielsogl
Copy link
Contributor

@danielsogl danielsogl commented Feb 19, 2026

Problem

set_global_rate_limit() is decorated with @handle_valkey_error(default_return=None), which silently swallows ValkeyError. If Valkey is unavailable when Blizzard returns HTTP 403:

  1. The rate limit flag is never stored in Valkey
  2. _check_rate_limit() sees no flag → allows requests through
  3. Subsequent requests keep hitting Blizzard, which is still rate-limiting
  4. This can cause extended bans from Blizzard

Fix

Add an in-memory timestamp (_rate_limited_until) on the BlizzardClient singleton as a fallback:

  • On HTTP 403 from Blizzard: store time.monotonic() + retry_after before attempting the Valkey write
  • On _check_rate_limit(): check the in-memory timestamp first, then fall through to the Valkey check

Since BlizzardClient uses the Singleton metaclass, this effectively rate-limits the entire process even when Valkey is down. The Valkey check remains for cross-worker coordination.

Impact

  • When Valkey is healthy: no behavior change (Valkey check still runs, in-memory check is redundant but cheap)
  • When Valkey is down: rate limiting still works via in-memory fallback, preventing Blizzard ban escalation

Summary by Sourcery

Bug Fixes:

  • Ensure global rate limiting still blocks requests when Valkey is unavailable by using an in-memory timestamp on the BlizzardClient singleton as a fallback to Valkey-based checks.

…able

set_global_rate_limit() is decorated with @handle_valkey_error which
silently swallows ValkeyError. If Valkey is down when Blizzard returns
HTTP 403, the rate limit flag is never stored, and subsequent requests
continue hitting Blizzard — potentially causing extended bans.

Add an in-memory timestamp (_rate_limited_until) as a fallback that
works even when Valkey is unavailable. Since BlizzardClient is a
singleton, this effectively rate-limits the entire process. The Valkey
check remains for cross-worker coordination; the in-memory check adds
defense in depth.
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 19, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Implements an in-memory per-process fallback rate-limiting mechanism in BlizzardClient to handle cases where Valkey is unavailable, by tracking a monotonic timestamp and checking it before the shared Valkey-based rate limit.

Sequence diagram for in-memory fallback rate limiting in BlizzardClient

sequenceDiagram
    actor Caller
    participant BlizzardClient
    participant CacheManager
    participant Valkey
    participant BlizzardAPI

    Caller->>BlizzardClient: request_resource()
    BlizzardClient->>BlizzardClient: _check_rate_limit()
    BlizzardClient->>BlizzardClient: remaining = _rate_limited_until - time.monotonic()
    alt in_memory_limit_active
        BlizzardClient-->>Caller: HTTP 429 Too Many Requests
    else no_in_memory_limit
        BlizzardClient->>CacheManager: is_being_rate_limited()
        CacheManager->>Valkey: check_global_rate_limit_flag
        Valkey-->>CacheManager: flag_or_error
        CacheManager-->>BlizzardClient: is_rate_limited
        alt valkey_indicates_rate_limit
            BlizzardClient->>CacheManager: get_global_rate_limit_remaining_time()
            CacheManager->>Valkey: get_retry_after
            Valkey-->>CacheManager: retry_after_seconds
            CacheManager-->>BlizzardClient: retry_after_seconds
            BlizzardClient-->>Caller: HTTP 429 Too Many Requests
        else not_rate_limited
            BlizzardClient->>BlizzardAPI: call_endpoint()
            BlizzardAPI-->>BlizzardClient: HTTP 403 Forbidden
            BlizzardClient->>BlizzardClient: _blizzard_forbidden_error()
            BlizzardClient->>BlizzardClient: _rate_limited_until = time.monotonic() + settings.blizzard_rate_limit_retry_after
            BlizzardClient->>CacheManager: set_global_rate_limit()
            CacheManager->>Valkey: write_global_rate_limit_flag
            Valkey-->>CacheManager: ok_or_error
            BlizzardClient-->>Caller: HTTP 403 Forbidden
        end
    end
Loading

Updated class diagram for BlizzardClient rate limiting

classDiagram
    class BlizzardClient {
        - CacheManager cache_manager
        - float _rate_limited_until
        - AsyncClient client
        + __init__()
        + _check_rate_limit() void
        + _blizzard_forbidden_error() HTTPException
    }

    class CacheManager {
        + is_being_rate_limited() bool
        + get_global_rate_limit_remaining_time() int
        + set_global_rate_limit() void
    }

    class Singleton {
    }

    class AsyncClient {
    }

    class HTTPException {
    }

    BlizzardClient ..> CacheManager : uses
    BlizzardClient ..> AsyncClient : uses
    BlizzardClient ..> HTTPException : raises
    BlizzardClient ..|> Singleton : metaclass
Loading

File-Level Changes

Change Details Files
Add an in-memory monotonic timestamp on BlizzardClient as a fallback rate-limit flag and integrate it into the existing rate limit check and forbidden-error handling.
  • Initialize a _rate_limited_until float attribute on BlizzardClient to track the rate-limit expiry time using time.monotonic().
  • Update _check_rate_limit to first compute remaining time from _rate_limited_until and immediately raise a 429 HTTP error if still rate-limited, before consulting Valkey via cache_manager.is_being_rate_limited().
  • Ensure _blizzard_forbidden_error sets _rate_limited_until using settings.blizzard_rate_limit_retry_after before writing the global rate limit flag to Valkey with cache_manager.set_global_rate_limit().
app/adapters/blizzard/client.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `app/adapters/blizzard/client.py:146-148` </location>
<code_context>
         - 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=int(remaining) or 1)
+
         if await self.cache_manager.is_being_rate_limited():
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider syncing the in-memory fallback with the Valkey remaining TTL to avoid divergence.

Because `_rate_limited_until` is only set in `_blizzard_forbidden_error`, the in-memory check can miss an active rate limit if Valkey reports it (via `is_being_rate_limited()` / `get_global_rate_limit_remaining_time()`) but then becomes unavailable. To keep them in sync, consider updating `_rate_limited_until` whenever Valkey indicates a rate limit is active, e.g. `self._rate_limited_until = time.monotonic() + remaining`.

```suggestion
        if await self.cache_manager.is_being_rate_limited():
            # Sync in-memory fallback with Valkey's remaining TTL
            remaining = await self.cache_manager.get_global_rate_limit_remaining_time()
            # Ensure in-memory fallback can still protect when Valkey becomes unavailable
            self._rate_limited_until = time.monotonic() + float(remaining)
            raise self._too_many_requests_response(
                retry_after=int(remaining) or 1
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

When Valkey reports an active rate limit, sync the in-memory
_rate_limited_until timestamp with Valkey's remaining TTL. This
ensures the in-memory fallback can still protect against Blizzard
requests if Valkey becomes unavailable mid-rate-limit window.

Addresses review feedback from sourcery-ai.
TeKrop
TeKrop previously approved these changes Feb 19, 2026
@TeKrop TeKrop dismissed their stale review February 19, 2026 19:00

Tests are failing

danielsogl and others added 2 commits February 21, 2026 11:48
…th.ceil

Two issues fixed:
- BlizzardClient singleton's _rate_limited_until persisted across tests,
  causing 429 responses in unrelated tests after any rate limit trigger
- int() truncation turned 4.99s into "4 seconds", mismatching expected
  "5 seconds" in assertions; math.ceil() preserves correct rounding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sonarqubecloud
Copy link

@TeKrop TeKrop merged commit 566bc3c into TeKrop:main Feb 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants