Rate Limiting: Edge per-IP, Origin per-Tier
smo1 intermediate 6 min read
ELI5
Two bouncers count visitors. The one at the edge counts everyone by IP address, 120 a minute, before anything else happens. The one at the API counts only signed-in callers, by account, with a bigger allowance for bigger plans, resetting at the top of each hour. Some public API doors have no counter of their own and rely on the edge bouncer.
Technical Deep Dive
Source: zoomies-edge src/middleware/rate-limit.ts:1-93, src/index.ts:31 (origin/main 5bdde59); purr-api internal/middleware/ratelimit.go:30-110, internal/database/redis.go:89-111, internal/router/router.go:126-171, 259-269, internal/models/user.go:124-167, internal/middleware/clientip.go:56-76, internal/handler/paid_link_handler.go:222-257, cmd/server/main.go:349-351 (bb6cfc2), read 2026-10-25.
Where limits run
flowchart TD A[Request to smo1.io] --> E["edge: 120 per IP per 60 s, Cache API counter"] E -->|over| E429[429 text, Retry-After] E -->|under| W[Worker routes] W --> P[purr-api] P --> PUB{"public route ?"} PUB -->|Yes| NOLIM[no origin limit] PUB -->|"POST /api/links keyless, x402 on"| X["x402: per IP per minute, plus global facilitator budget"] PUB -->|No| AUTH[RequireAuth then resolveTier] AUTH --> T["TierBasedRateLimit: rate:user:id:window, 3600 s fixed window"] T -->|over| P429[429 JSON, Retry-After]Origin tier limits
From models.GetTierLimits, wired by router.TierRateLimits():
| Tier | Requests per hour | Monthly links |
|---|---|---|
| free | 60 | 5 |
| pro | 600 | 100 |
| business | 6,000 | 500 |
| early_adopter | 1,000 | 50 |
The tier is the users.tier value read by resolveTier on every authenticated request (smo1-004). A caller with no user context (the internal API key) falls back to the middleware’s default of 100 per hour keyed on client IP (main.go:350).
Algorithm: fixed window
CheckRateLimit runs INCR rate:{identifier}:{unix/window} and EXPIRE window in one pipeline and allows while the count is at most the limit (redis.go:89-111). The window resets at fixed boundaries, so a caller can spend a full allowance just before the boundary and another just after. The earlier card’s “sliding window” description was wrong.
Headers on every limited response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, plus Retry-After on 429. CORS exposes the first three (router.go:75).
Edge limiter
Per IP from CF-Connecting-IP, 120 per 60 s, counted in caches.default with a TTL of the remaining window plus 5 s. The Cache API is per data centre, so the effective global limit is per IP per Cloudflare colo.
Public routes with no origin limit
/api/links/slug/:slug, /api/links/resolve, /api/analytics/track, /api/links/:id/verify, /api/links/:id/protection-info and /api/webhooks/polar sit before the protected group. The IP limiter RateLimit() exists but is mounted nowhere. Direct calls to purr-api-production.up.railway.app bypass the edge limiter entirely. /verify has its own brute-force guard in Postgres (smo1-010).
Fail-open
Any Redis error lets the request through (ratelimit.go:38-42, 84-87), and a Redis that fails to connect at boot leaves mw.RateLimit nil, so no tier limit is mounted at all (main.go:349-351). The x402 limiter fails open the same way (paid_link_handler.go:231-233). Whether production has Redis at all is UNVERIFIED: the session ledger’s 2026-10-18 census of the Railway production environment lists only Postgres and purr-api. If that census is complete, no origin tier limit is running in production and the edge limiter is the only one.
Key Terms
- Fixed window — counter per aligned time bucket; resets at the boundary
- Sliding window — counts over a rolling lookback; not what smo1 uses
- TierBasedRateLimit — origin limiter keyed
user:{id}with the tier’s hourly limit - CloudflareClientIP — purr-api middleware that trusts
CF-Connecting-IPonly from a Cloudflare peer
Q&A
Q: What are the hourly request limits per tier? A: free 60, pro 600, business 6,000, early_adopter 1,000, on authenticated purr-api routes. Unidentified callers on those routes get the default 100 keyed by IP.
Q: What happens if Redis is unreachable? A: Requests are allowed. A Redis error during a check fails open, and if Redis was down at boot the tier limiter is not mounted at all. The edge limiter is unaffected because it uses the Cloudflare Cache API.
Q: What is the difference between a fixed window and a sliding window? A: A fixed window counts per aligned bucket and resets at the boundary, so up to twice the limit can pass around a boundary. A sliding window counts requests in the trailing period and has no such burst. purr-api uses a fixed one-hour window; the edge uses a fixed one-minute window.
Q: Which public routes have no rate limit at origin?
A: The slug lookup, resolve, analytics track, protection verify, protection-info and Polar webhook routes. They rely on the edge limiter when called through smo1.io, and have none when called on the Railway hostname.
Examples
A free-tier user’s script polls GET /api/links 70 times between 10:00 and 10:59 UTC. Calls 61 to 70 get 429 with Retry-After counting down to 11:00:00, when the key rate:user:<id>:<bucket> changes and the count starts at 1 again.
neighbors on the map
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Caching Strategy: What Is Actually Cached deciding whether a stale redirect is a cache problem
- x402 Paid Anonymous Links: One Payment Buys One Link working on POST /api/links or anything in internal/x402