Coding Software Engineer
Create a distributed rate limiter that uses a token-bucket design and replenishes tokens lazily. Two classes are supplied for you:
DistributedCache: A distributed key/value system, such as Redis, exposing get() and put()TokenBucket: A dataclass that stores the current rate-limit state for one userYou must write these two functions:
refill_token_bucket(bucket, current_time): Determine the on-demand refill amount from the time passed and apply itallow_request(cache, user_id, tokens_requested): Decide whether a user can spend the requested tokens and deduct them when permittedThis approach is often applied to:
import time
from dataclasses import dataclass
class DistributedCache:
"""
A distributed cache such as Redis that offers atomic get/put calls.
Treat this class as prebuilt; serialization is handled internally.
"""
def get(self, key: str) -> object | None:
"""Retrieve the value for key, or None when no such key is stored."""
pass
def put(self, key: str, value: object) -> None:
"""Save value under key."""
pass
@dataclass
class TokenBucket:
"""Stores one user's token-bucket information."""
tokens: float # Tokens currently available
last_refill_time: float # Time of the most recent refill (Unix time)
capacity: int # Largest token count permitted in the bucket
refill_rate: float # Tokens replenished each second
cache = DistributedCache()
# The user's initial request
result = allow_request(cache, "member_47", tokens_requested=1)
print(result) # True (a new bucket begins at its capacity)
# Rapidly use the other 99 available tokens
for _ in range(99):
allow_request(cache, "member_47", tokens_requested=1)
# With 100 tokens spent, another request is denied
result = allow_request(cache, "member_47", tokens_requested=1)
print(result) # False (no tokens remain)
# Allow tokens to be replenished...
time.sleep(1) # Sleep for one second (when refill_rate is 10/sec)
# The user can make a request again
result = allow_request(cache, "member_47", tokens_requested=1)
print(result) # True (10 tokens were replenished)
Write refill_token_bucket so it works out the number of tokens earned since the previous refill time.
Why use lazy refilling? Rather than continuously running a job that refills every user's bucket, token replenishment happens only when that user sends a request. That avoids unnecessary work in systems with millions of users, most of whom may currently be inactive.
def refill_token_bucket(bucket: TokenBucket, current_time: float) -> TokenBucket:
"""
Refill a bucket on demand using the elapsed time.
Args:
bucket: Existing state of the token bucket
current_time: Current Unix time in seconds
Returns:
A TokenBucket containing the replenished token count and refreshed timestamp
"""
pass
last_refill_timeelapsed_time * refill_rate tokenscapacitylast_refill_time to current_timecurrent_time < last_refill_time, tokens must not be deducted# Test 1: A normal one-second refill
bucket = TokenBucket(tokens=42, last_refill_time=200.0, capacity=90, refill_rate=8.0)
updated = refill_token_bucket(bucket, current_time=201.0)
assert updated.tokens == 50 # 42 + (1 * 8)
assert updated.last_refill_time == 201.0
# Test 2: Refilling cannot exceed the bucket limit
bucket = TokenBucket(tokens=87, last_refill_time=200.0, capacity=90, refill_rate=8.0)
updated = refill_token_bucket(bucket, current_time=201.0)
assert updated.tokens == 90 # Limited to capacity rather than 95
# Test 3: Identical timestamps produce no refill
bucket = TokenBucket(tokens=42, last_refill_time=200.0, capacity=90, refill_rate=8.0)
updated = refill_token_bucket(bucket, current_time=200.0)
assert updated.tokens == 42 # The value stays unchanged
# Test 4: A long inactive period fills the bucket
bucket = TokenBucket(tokens=0, last_refill_time=200.0, capacity=90, refill_rate=8.0)
updated = refill_token_bucket(bucket, current_time=300.0) # 100 seconds later
assert updated.tokens == 90 # The bucket reaches full capacity
Implement allow_request to verify that a user can afford a request and to spend those tokens as one operation.
def allow_request(
cache: DistributedCache,
user_id: str,
tokens_requested: int = 1,
capacity: int = 100,
refill_rate: float = 10.0
) -> bool:
"""
Permit a request and deduct its tokens when sufficient tokens exist.
Otherwise, deny it without deducting anything.
Args:
cache: The distributed cache to use
user_id: The user's unique ID
tokens_requested: Token quantity to spend (default: 1)
capacity: Capacity assigned to buckets created for new users
refill_rate: Per-second refill speed assigned to new users
Returns:
True when tokens were spent and the request is accepted; otherwise False
"""
pass
user_id, such as f"rate_limit:{user_id}"refill_token_buckettokens >= tokens_requestedTrueFalse# Test 1: A first-time user starts with a full bucket
cache = DistributedCache()
assert allow_request(cache, "first_member") == True
# Test 2: Spend every token
cache = DistributedCache()
for _ in range(100):
assert allow_request(cache, "account_r") == True # The first 100 are accepted
assert allow_request(cache, "account_r") == False # Request 101 is rejected
# Test 3: Each user receives an independent bucket
cache = DistributedCache()
for _ in range(100):
allow_request(cache, "client_red")
assert allow_request(cache, "client_red") == False # client_red has spent its bucket
assert allow_request(cache, "client_blue") == True # client_blue still has available tokens
# Test 4: Spend more than one token in a single request
cache = DistributedCache()
assert allow_request(cache, "batch_user", tokens_requested=50) == True # 50 tokens remain
assert allow_request(cache, "batch_user", tokens_requested=50) == True # No tokens remain
assert allow_request(cache, "batch_user", tokens_requested=1) == False # The bucket is empty
Several servers can receive requests for one user at the same time in a distributed deployment. A straightforward implementation contains a race condition:
Server A: GET member_47 -> bucket(tokens=10)
Server B: GET member_47 -> bucket(tokens=10)
Server A: tokens - 1 = 9, PUT member_47 -> bucket(tokens=9)
Server B: tokens - 1 = 9, PUT member_47 -> bucket(tokens=9)
# Both calls were accepted even though just one token was recorded as spent!
What can you do to eliminate this race condition?
What compromises does each option involve?
How should lock acquisition failures and timeouts be handled?
With Redis, the complete read, refill, check, and deduction can run atomically in a Lua script:
# Redis Lua-script approach in pseudocode
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local tokens_requested = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill_rate = tonumber(ARGV[3])
local current_time = tonumber(ARGV[4])
local bucket = redis.call('HGETALL', key)
-- Decode the bucket, refill it, validate tokens, deduct, and persist it
-- The entire sequence executes atomically
"""
def allow_request_atomic(redis_client, user_id: str, tokens: int = 1) -> bool:
result = redis_client.eval(
RATE_LIMIT_SCRIPT,
keys=[f"rate_limit:{user_id}"],
args=[tokens, 100, 10.0, time.time()]
)
return result == 1