DEV Community

Xitong Shan
Xitong Shan

Posted on

A Redis Lua Rate Limiter for FastAPI That Returns Useful 429 Headers

Rate limiting is easy to describe and surprisingly easy to implement incorrectly. A request counter must change atomically, the counter needs a reliable expiry, different endpoints need different identities, and blocked clients need enough information to back off.

This article explains the small Redis-backed limiter in my public FastAPI project, mini-agent. It protects an authenticated AI-agent chat endpoint and a login endpoint. The design uses one Lua script for the counter and expiry, hashes identities before placing them in Redis keys, and returns 429 Too Many Requests with Retry-After and rate-limit headers.

The examples are pinned to this public commit, not an unspecified future version of the repository.

Define the result before the storage details

The rest of the application should not need to understand Redis return values. The limiter translates them into a small data object:

@dataclass
class RateLimitResult:
    allowed: bool
    scope: str
    limit: int
    count: int
    remaining: int
    retry_after_seconds: int
Enter fullscreen mode Exit fullscreen mode

scope distinguishes policies such as chat_per_minute, chat_per_day, login_ip_per_minute, and login_user_ip_per_minute. Returning the count and remaining allowance makes the decision observable without requiring callers to read the Redis key.

The full implementation is in rate_limit.py.

Keep increment, first expiry, and TTL in one Lua script

A naive implementation issues separate INCR and EXPIRE commands. If the process fails between them, the counter may be left without an expiry. Concurrent requests can also observe an incomplete update.

Redis executes a Lua script atomically, so this implementation performs the related operations together:

local current = redis.call("INCR", KEYS[1])

if current == 1 then
    redis.call("EXPIRE", KEYS[1], tonumber(ARGV[1]))
end

local ttl = redis.call("TTL", KEYS[1])

return {current, ttl}
Enter fullscreen mode Exit fullscreen mode

The first hit creates the counter and starts its expiry. Later hits increment the same key without extending the window. The Python layer calculates the decision:

count_raw, ttl_raw = await self.client.eval(
    _FIXED_WINDOW_SCRIPT, 1, key, window_seconds
)

count = int(count_raw)
ttl = int(ttl_raw)
if ttl < 0:
    ttl = window_seconds

allowed = count <= limit
remaining = max(limit - count, 0)
Enter fullscreen mode Exit fullscreen mode

This is an expiry-backed fixed window anchored by the first hit. It is not a sliding-window algorithm and it is not aligned to wall-clock minute boundaries. That simplicity is acceptable for many APIs, but it permits bursts around the moment one key expires and another starts.

There is also a recovery detail worth noticing. If TTL unexpectedly returns a negative value, the code reports the configured window as the retry delay, but it does not repair the missing expiry. A hardened version should either restore the expiry inside the Lua script or fail closed and emit an operational alert.

Build scoped keys without storing raw identities

The Redis key combines the policy scope, window size, and a SHA-256 digest:

def hash_identity(identity: str) -> str:
    return hashlib.sha256(identity.encode("utf-8")).hexdigest()

def build_key(self, *, scope, identity, window_seconds):
    identity_hash = hash_identity(identity)
    return f"rate:{scope}:{window_seconds}:{identity_hash}"
Enter fullscreen mode Exit fullscreen mode

This prevents raw user IDs, usernames, and IP addresses from appearing directly in normal key listings. It does not make low-entropy identities secret: a deterministic, unsalted hash of a known IP range can be guessed offline. If the threat model requires stronger pseudonymization, use an HMAC with a rotated server-side key and document how rotations affect active windows.

Including the scope prevents a login attempt from consuming the chat allowance. Including the window length avoids a collision if the same scope name is mistakenly reused with different durations, although explicit unique scope names remain clearer.

Apply two policies to authenticated chat

The chat endpoint checks both a per-minute and per-day limit against the authenticated user_id:

per_minute = await check_limit_or_raise(
    limiter=limiter,
    scope="chat_per_minute",
    identity=current_user.user_id,
    limit=CHAT_RATE_LIMIT_PER_MINUTE,
    window_seconds=60,
)

await check_limit_or_raise(
    limiter=limiter,
    scope="chat_per_day",
    identity=current_user.user_id,
    limit=CHAT_RATE_LIMIT_PER_DAY,
    window_seconds=60 * 60 * 24,
)
Enter fullscreen mode Exit fullscreen mode

FastAPI dependency injection makes this run after authentication but before the route body. get_rate_limited_current_user receives the authenticated user, checks the limits, and saves the successful minute result on request.state.

The chat route then exposes the successful minute allowance in both response headers and its structured response. The daily result is enforced but is not included in a successful response. If clients need both budgets, return or attach both results rather than implying one set of headers describes every active policy.

Rate-limit login without helping username enumeration

The login route has a different identity problem because there is no authenticated user yet. The implementation checks:

  1. attempts from one client IP; and
  2. attempts for one username + IP pair.

Both checks happen regardless of whether the username exists. That avoids creating a simple side channel in which nonexistent usernames skip one rate-limit path.

The code includes an important proxy warning. It currently takes the first address from X-Forwarded-For when present, otherwise it uses request.client.host. A public client can forge X-Forwarded-For unless a trusted reverse proxy strips and rewrites it. Production code must trust forwarding headers only when the direct peer is an approved proxy or when the framework's proxy configuration has already validated the chain.

This is not a minor deployment detail. If arbitrary clients can choose the identity used by the IP limiter, they can evade one of the login protections.

Make a blocked response actionable

When a limit is exceeded, the helper raises FastAPI's HTTPException with status 429:

def rate_limit_headers(result):
    return {
        "X-RateLimit-Scope": result.scope,
        "X-RateLimit-Limit": str(result.limit),
        "X-RateLimit-Remaining": str(result.remaining),
        "X-RateLimit-Reset": str(result.retry_after_seconds),
    }

def raise_rate_limited(result):
    headers = rate_limit_headers(result)
    headers["Retry-After"] = str(result.retry_after_seconds)
    raise HTTPException(
        status_code=429,
        detail=f"请求过于频繁,请 {result.retry_after_seconds} 秒后再试。",
        headers=headers,
    )
Enter fullscreen mode Exit fullscreen mode

Retry-After gives an automated client a concrete delay. The additional headers help a UI explain which policy was reached. Here, X-RateLimit-Reset contains seconds remaining, not an absolute timestamp. Because header conventions differ across APIs, document that meaning or adopt the current standardized RateLimit fields consistently.

Clients should still add jitter and avoid firing every queued request at the exact expiry boundary.

Test the behavior through the API boundary

The repository has two concise integration tests in test_rate_limit.py:

  • with the login username-and-IP limit set to two, two bad passwords return 401 and the third attempt returns 429;
  • with the per-minute chat limit set to two, two authenticated chat calls return 200 and the third returns 429.

Both blocked responses must include Retry-After. Those checks validate the observable contract rather than only the internal counter.

Additional tests I would add before deploying this pattern include:

  • simultaneous hits at the limit boundary;
  • key expiry followed by a fresh allowed request;
  • separate counters for two users and two scopes;
  • a simulated key with no TTL and the intended repair behavior;
  • a daily-limit rejection after the minute limit succeeds;
  • trusted-proxy and forged-forwarding-header cases;
  • Redis timeout behavior, with an explicit fail-open or fail-closed policy.

The public CI run for the pinned commit completed successfully. That is useful evidence for this exact revision, but a passing test suite does not resolve the algorithm's documented limitations.

Know when to move beyond this design

An expiry-backed counter is compact and fast. It works well when approximate fixed-window fairness is acceptable and the main goals are abuse resistance and predictable resource use.

It is a weaker fit when customers require smooth quotas, globally coordinated multi-region enforcement, or billing-grade accuracy. Those cases may need a sliding-window log or counter, token bucket, gateway-level limiter, or a dedicated distributed rate-limit service. Whatever algorithm you choose, keep the same application-level disciplines: scoped identities, atomic state changes, useful 429 responses, proxy-aware client identity, and tests at the HTTP boundary.

Disclosure: This article describes a self-built public demonstration project, not a client production incident. It was prepared with AI-assisted editing. The author is responsible for reviewing the code, technical claims, links, and final text before publication.``

Top comments (0)