Tier-Based Rate Limiting
smo1 intermediate 5 min read
ELI5
Rate limiting is like a bouncer counting how many people enter a club per hour. Free members get 60 entries per hour. Pro members get 600. Business members get 6,000. The bouncer uses a clicker (Redis) that counts every entry and automatically forgets entries older than one hour. If the clicker breaks, the bouncer lets everyone in rather than locking the doors — better to be too permissive than completely broken.
Technical Deep Dive
Tier Limits
| Tier | Hourly limit | Use case |
|---|---|---|
| free | 60 | Casual users, trial accounts |
| pro | 600 | Power users, small teams |
| business | 6,000 | Enterprises, high-volume integrations |
| early_adopter | 1,000 | Legacy grandparented accounts |
These limits apply to authenticated requests per user ID or API key ID. Anonymous requests share a per-IP limit (typically lower, e.g., 30/hour).
Sliding Window Algorithm
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e8f4f8', 'primaryTextColor': '#2d3748', 'primaryBorderColor': '#90cdf4', 'lineColor': '#718096', 'secondaryColor': '#f0fff4', 'tertiaryColor': '#fefcbf'}}}%%flowchart TD A[Incoming Request] --> B{Authenticated ?} B -->|Yes| C["Key = rate:user:{user_id}:{hour_bucket}"] B -->|No| D["Key = rate:ip:{ip}:{hour_bucket}"] C --> E[INCR key in Redis] D --> E E --> F{"Value ≤ limit ?"} F -->|Yes| G[Allow request] F -->|No| H[Reject with 429] G --> I[Set headers] H --> I I --> J["X-RateLimit-Limit<br/>X-RateLimit-Remaining<br/>X-RateLimit-Reset<br/>Retry-After"]Key format: rate:{identifier}:{window_bucket}
identifier=user:{user_id}orapikey:{key_id}orip:{ip_address}window_bucket= Unix timestamp of the current hour start (e.g.,1714291200)
TTL: 3600 seconds (1 hour). Redis auto-expires the key, so old windows clean themselves up.
Why Sliding Window?
A fixed window resets at the top of each hour. A burst of 60 requests at 09:59 and another 60 at 10:01 allows 120 requests in 2 minutes — unfair to the limit.
A sliding window tracks requests within a rolling 1-hour lookback. In practice, purr-api uses a sliding window log approximation: each request is logged with a timestamp, and the count is the number of logs within the last hour. This is more accurate but more Redis memory-intensive.
For simplicity and performance, the implementation may use a sliding window counter (the current hour bucket + a fraction of the previous hour bucket) rather than a full log.
Response Headers
Every rate-limited response includes standard headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds until the client should retry (only on 429) |
Fail-Open Behaviour
If Redis is unreachable (network partition, overload, maintenance):
// Pseudocode from middlewarecount, err := redis.Incr(ctx, key)if err != nil { // Fail open: allow the request return next(c)}Rationale: A rate limiter should never be a single point of failure. Better to serve requests without limits than to deny all service. Operational monitoring should alert on Redis errors so the team can investigate.
Middleware Registration
In internal/router/router.go:
// Public routes: no rate limitingpublic := router.Group("")public.GET("/:identifier", redirectHandler)public.GET("/api/links/slug/:slug", linkHandler.GetBySlug)
// Protected routes: tier-based rate limitingprotected := router.Group("")protected.Use(middleware.RequireAuth())protected.Use(middleware.RateLimit())protected.GET("/api/links", linkHandler.List)protected.POST("/api/links", linkHandler.Create)// ... all other authenticated endpointsAnonymous Rate Limiting
Unauthenticated requests (e.g., slug resolution, analytics tracking) use IP-based keys:
- Key:
rate:ip:{cf_connecting_ip}:{hour_bucket} - Limit: configurable (default 30/hour for sensitive endpoints, higher for public redirects)
This prevents abuse of public endpoints without requiring account creation.
Key Terms
- Sliding window → Rate limiting algorithm that counts requests within a rolling time period rather than resetting at fixed intervals
- Fail-open → Design choice to allow requests when the rate limiter is unavailable, avoiding total service outage
- Window bucket → Time-aligned key suffix (e.g., hour start timestamp) for grouping rate limit counters
- Rate limit headers → Standard HTTP response headers (
X-RateLimit-*) informing clients of their quota status - Tier-based → Different limits per subscription level (free/pro/business)
Q&A
Q: Why Redis instead of in-memory counters? A: In-memory counters do not work across multiple API server instances. Redis provides a shared, atomic counter that all purr-api instances can increment consistently.
Q: Can a user with multiple API keys bypass limits? A: No. API key rate limit keys include the key ID, but the user’s overall tier limit still applies to their account. The more restrictive of the two limits wins.
Q: What happens when a user upgrades from free to pro?
A: The next request will read the updated subscription_tier from the User record and use the higher limit immediately. Old rate limit counters are not migrated — they expire naturally within 1 hour.
Q: Why 60/600/6000 instead of round numbers like 100/1000/10000? A: 60 aligns with “1 per minute” mental model for free users. 600 is “10 per minute” for pros. 6000 is “100 per minute” for business. These are psychologically intuitive and map well to typical usage patterns.
Examples
Think of rate limiting like a public swimming pool:
- Free tier is the general admission lane — you can swim 60 laps per hour, enough for casual exercise
- Pro tier is the lap-swim membership — 600 laps per hour, serious training
- Business tier is the Olympic team booking — 6,000 laps per hour, they practically own the pool
- Redis is the lifeguard’s clicker — every time someone enters the water, the clicker increments
- Sliding window is the lifeguard looking at the last 60 minutes on their watch, not just the clock on the wall
- Fail-open is the lifeguard dropping their clicker in the pool — instead of closing the pool, they let everyone swim while they get a new clicker
neighbors on the map
- Multi-Strategy Authentication debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Multi-Layer Caching Strategy debugging stale link data