CRUMB a card from devarno-cloud

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

TierHourly limitUse case
free60Casual users, trial accounts
pro600Power users, small teams
business6,000Enterprises, high-volume integrations
early_adopter1,000Legacy 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} or apikey:{key_id} or ip:{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:

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds until the client should retry (only on 429)

Fail-Open Behaviour

If Redis is unreachable (network partition, overload, maintenance):

// Pseudocode from middleware
count, 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 limiting
public := router.Group("")
public.GET("/:identifier", redirectHandler)
public.GET("/api/links/slug/:slug", linkHandler.GetBySlug)
// Protected routes: tier-based rate limiting
protected := 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 endpoints

Anonymous 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