CRUMB a card from devarno-cloud

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():

TierRequests per hourMonthly links
free605
pro600100
business6,000500
early_adopter1,00050

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-IP only 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