Build a Dual Fixed-Window Rate-Limiting Middleware
HeadwaySoftware Engineer · OnsiteLast updated
Tests your ability to design and implement a thread-safe, dual fixed-window rate limiter and integrate it cleanly as HTTP middleware in an asynchronous Python framework.
Problem Statement
You are building an API gateway middleware for a FastAPI application running on Uvicorn. To protect downstream dependencies and ensure fair resource allocation, incoming requests must satisfy two independent rate limits simultaneously:
- Global Limit: No more than 100 requests per 60-second window across all clients.
- Per-Domain Limit: No more than 3 requests per 1-second window for any specific client domain (extracted from the
Hostheader or client identity).
If either limit is exceeded, the middleware must immediately reject the request with HTTP status 429 Too Many Requests without forwarding the call to downstream handlers. If the request is permitted, both the global counter and the domain-specific counter must be incremented atomically.
Implement the rate-limiting core logic and the FastAPI middleware.
Requirements
- Use a fixed-window counter approach where the window boundaries are aligned with epoch time:
window_id = int(timestamp // window_size_seconds). - When a window rolls over, past counts should be discarded or reset lazily to prevent unbounded memory growth.
- Make your implementation thread-safe / concurrency-safe against asynchronous tasks running on the same process.
- If rejected, return a meaningful header or response indicating which limit was breached (e.g.,
"Global rate limit exceeded"vs"Domain rate limit exceeded").
Interfaces
class DualFixedWindowRateLimiter:
def __init__(
self,
global_limit: int = 100,
global_window_sec: int = 60,
domain_limit: int = 3,
domain_window_sec: int = 1,
):
"""Initialize counters, locks, and window configurations."""
pass
def is_allowed(self, domain: str, now: float | None = None) -> tuple[bool, str | None]:
"""
Check and consume allowance for a request.
Returns:
(True, None) if the request is permitted.
(False, reason) if rejected, where reason is 'global' or 'domain'.
"""
pass
Examples
Example 1: Normal throughput within limits
t = 10.00: Domainapi.partner-a.comsends 3 requests -> All 3 return(True, None).t = 10.50: Domainapi.partner-a.comsends a 4th request -> Returns(False, 'domain')because 3 req/sec is exceeded.t = 11.01: Domainapi.partner-a.comsends a request -> Returns(True, None)because a new 1-second window has started.
Example 2: Global limit saturation
- Over 60 seconds, 100 requests are distributed across 50 distinct domains (2 requests each, well within the 3 req/sec limit).
- The 101st request arrives at
t = 59.9from any domain -> Returns(False, 'global').
Constraints & Edge Cases
- Concurrent async requests running concurrently via
asyncio. - Missing or malformed
Hostheader (treat as"unknown"or reject early). - Memory leaks from inactive domains: stale domain windows must be cleaned up periodically or pruned when evaluated.
Hints
Hint 1
Compute the current window key as `int(now // window_seconds)` so that counter resets happen implicitly whenever the key changes.
Hint 2
Evaluate both limits before incrementing counters, or ensure that if the second limit check fails, the first limit's increment is rolled back.
Hint 3
Use an `asyncio.Lock` or standard `threading.Lock` depending on whether state access is synchronous or involves async I/O, ensuring atomicity across concurrent requests.
Follow-up questions
- How does the fixed-window algorithm behave at boundary edges (the 'burst at the boundary' problem), and how would you transition to a sliding window or token bucket?
- If your FastAPI service scales horizontally to 10 Uvicorn workers or across multiple Kubernetes pods, how would you adapt this state management using Redis?
- How would you implement an eviction strategy for inactive domain keys without degrading request latency under heavy load?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.