Designing Rate Limits That Don't Punish Success
Designing Rate Limits That Don't Punish Success
Rate limiting is often framed as punishment. You hit a limit, you get a 429, your app breaks. But rate limits shouldn't be punitive—they should be protective. A good rate limiting system lets legitimate traffic flow and only slows down abusers. This is how we think about rate limiting at Forge.
Our Rate Limit Tiers
Every Forge account has a rate limit based on their plan:
- Free: 60 requests/minute, 1,000 requests/day
- Starter ($29/mo): 300 requests/minute, 100,000 requests/month
- Pro ($99/mo): 1,000 requests/minute, 1,000,000 requests/month
- Enterprise: Custom
These aren't arbitrary. We analyzed customer usage patterns and set limits where 95% of legitimate customers never come close.
Sliding Window Counters
A naive rate limiting approach: each minute, reset a counter. But this has edge cases. If someone makes 60 requests at 00:59 and 60 at 01:01, they've made 120 requests in 2 seconds—violating the spirit of the limit.
Instead, we use sliding window counters. Our rate limiter tracks a rolling 60-second window for per-minute limits. For daily limits, we use a 24-hour window. Each request advances the window. This is more fair and harder to game.
Implementation-wise, this lives in Redis with a simple Lua script:
local key = 'rl:' .. api_key .. ':' .. os.time() / 60
local current = redis.call('incr', key)
if current == 1 then
redis.call('expire', key, 120)
end
if current > limit then
return {error, reset_time}
else
return {ok, current, limit}
endThis gives us microsecond-level tracking without polling a database on every request.
Rate Limit Headers
Here's where good rate limiting becomes developer-friendly. Every response includes three headers:
- X-RateLimit-Limit: Your limit for this window (e.g., 300)
- X-RateLimit-Remaining: How many requests you have left (e.g., 287)
- X-RateLimit-Reset: Unix timestamp when the window resets
With these headers, a smart client can make intelligent decisions. If Remaining drops below 10, you might batch requests or queue them. You never have to guess when you're close to the limit. This visibility is critical for building robust integrations.
The 429 Response and Retry-After
When a client exceeds their rate limit, we return a 429 Too Many Requests with a Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Reset: 1724342700
{
"status": "error",
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded your rate limit. Please retry after 45 seconds.",
"doc_url": "https://forge-api.dev/docs/rate-limits"
}
}Retry-After is in seconds. A well-behaved client reads this and waits. A naive client that immediately retries will keep hitting 429s, but they're not causing harm to others—rate limiting is working exactly as intended.
Burst Allowance
Real traffic has bursts. A user might make no requests for 5 minutes, then suddenly need 150 in rapid succession. Should this fail? No. We allow a 30-second burst allowance on top of the per-minute limit. If your limit is 300 req/min, you can make up to 150 requests in a single second, as long as the rolling 60-second average stays under 300. This makes the API feel responsive without compromising fairness.
Fairness Without Micromanagement
We don't rate limit by endpoint or by operation cost. We treat all requests equally. This keeps our system simple and fair. If we started charging different prices for different endpoints, we'd have to explain those costs, maintain them as the product changes, and handle disputes.
Monitoring and Adjustment
We monitor 99th percentile request latency per plan tier. If high-tier customers are regularly hitting their limits, we know our estimates were wrong. We've adjusted limits twice based on real usage data. This is data-driven rate limiting.
Rate limiting isn't about saying no to customers. It's about saying yes fairly, and helping clients respect those boundaries without surprises.