Skip to Content
How the backend is builtAPI rate limiting

API rate limiting

Rate limiting stops abuse: brute-force logins, scraping public catalogs, expensive admin requests, and flooding notification webhooks. Policies live in MongoDB and are editable from the admin app (super admins) without redeploying the server.

How a request flows

  1. CORS — browser origin check.
  2. requestIdMiddleware — assigns or honors X-Request-Id.
  3. rateLimitMiddleware — loads cached config, matches policies, updates counters, may return 429.
  4. Route + handler — auth, validation, business logic.

If global rate limiting is disabled (enabled: false in config), step 3 does nothing useful beyond skipping work.

Glossary (quick reference)

TermWhat it means
PolicyOne rule: which URLs, which HTTP methods, how many requests allowed in a window, how to count them, and whether to block.
WindowA time slice (windowSeconds). Counts reset or roll when the window advances.
LimitMax allowed “usage” in that policy’s sense (see Algorithms below).
IdentityWho we count: by IP, by logged-in user id, both (prefer user), or internal worker.
Modeoff / shadow / enforce-soft / enforce — whether we only observe, soft-block, or hard-block.
StoreWhere counters live: memory (single process) or mongo (shared across instances).
Config cacheIn-process copy of the Mongo config; refreshes about every 30 seconds after load or invalidation.

Policy fields (what each thing does)

Each policy in the database (and in the admin UI) has these fields:

FieldPurpose
idStable unique key (e.g. users.read). Used in logs, events, and API paths.
nameHuman label in admin UI.
routeGroupTag for reporting (groups related limits in monitoring).
pathPrefixesList of URL path prefixes. A request matches if its path equals a prefix or starts with prefix + '/'. Example: /api/v1/users matches /api/v1/users/me.
methodsOptional. If set, only these HTTP methods match (uppercase in storage, e.g. GET, POST). If omitted, any method matches the paths.
identityip — count per client IP. user — per Bearer access token user id (decoded without running full route auth). user_or_ip — use user id when present, else IP. internal — only applies when x-internal-key is present (worker calls).
windowSecondsLength of the rate window in seconds (for fixed / sliding) or the period used to derive token refill rate for token_bucket.
limitMaximum count (fixed/sliding) or bucket capacity (token bucket).
algorithmfixed — count requests in the current window. sliding — smoother estimate using current + previous window. token_bucket — burst-friendly refill (good for expensive endpoints).
modeoff — ignore. shadow — count and log “would block” but always allow the request. enforce-soft — allow up to about the limit before blocking. enforce — block when over limit.
weightWhen several policies match, they are evaluated in weight order (higher first). The most restrictive outcome (lowest remaining quota or blocked) wins for the response.
allowlistList of raw identity strings (e.g. ip:203.0.113.5, user:<mongoId>) that skip this policy.

Super-admin bypass

If the user is a super admin (isAdmin: true on the user document), policies with identity: user are skipped for that user. IP-based and user_or_ip policies still apply when falling back to IP.

GET /health and GET /ready are never rate limited.

Client IP detection

The limiter prefers CF-Connecting-IP (Cloudflare), otherwise the last entry in X-Forwarded-For (typical reverse-proxy shape). If neither is present, identity may show as ip:unknown.

Algorithms (what each one does)

AlgorithmBehaviorGood for
fixedOne counter per identity per time bucket (e.g. per minute). Simple; can allow short bursts at bucket edges.Auth walls, coarse caps.
slidingUses current window count plus a weighted fraction of the previous window — closer to “rolling” fairness.Public read APIs.
token_bucketTokens refill at rate limit / windowSeconds per second; each request consumes one token up to limit. Allows controlled bursts.Expensive admin POSTs.

Modes (safety ramp)

ModeEffect
offPolicy does not run.
shadowCounts and records shadow_violation events but never returns 429 for that policy.
enforce-softBlocks only after roughly triple the configured limit.
enforceBlocks when the limit is exceeded.

Global switch: enabled: false on the singleton config turns rate limiting off for the whole app (after cache refresh).

Environment variables

Set in hono-backend .env or process env:

VariableDefaultMeaning
RATE_LIMIT_STOREmemorymemory = counters in Node RAM (fast; single instance; lost on restart). mongo = counters in MongoDB (shared; survives restart; use with multiple app instances).
RATE_LIMIT_EVENT_SAMPLE_RATE0.01Fraction of allowed requests that write a sampled event (01). Blocked and shadow outcomes are recorded without sampling.

MongoDB collections (what persists where)

Collection / modelRole
RateLimitConfig (key: 'global')The enabled flag and full policies[] array. Seeded on first access if missing.
RateLimitCounterPer-key counters and token-bucket state when RATE_LIMIT_STORE=mongo. TTL on documents cleans old windows.
RateLimitEventOptional audit stream: blocked, shadow violations, sampled allows. Short TTL (e.g. ~14 days).
RateLimitRollupDaily aggregates for dashboards (may be sparse until a job fills them).
RateLimitAuditAdmin change log: who changed config/policies and before/after snapshots.

Admin UI: how to use it

Requires admin-frontend logged in as staff.

  1. Rate Limits (/rate-limits) — table of policies; select one to edit limit, window, mode, algorithm, identity, prefixes, allowlist. Toggle Enable all / Disable all for the global switch. Recent changes shows audit entries.
  2. Rate Limit Monitoring (/rate-limits/monitoring) — rollups and recent events (blocked vs shadow vs sampled allowed).

Permissions:

  • VIEW_STATS — read config summary, events, metrics (GET endpoints).
  • MANAGE_RATE_LIMITSsuper admin only in the backend role map: change policies, global enable, create/delete policy rows, dry-run test.

If your account is only manager / creator, you will not see the Rate Limits nav items unless you are isAdmin: true.

HTTP API (for automation or curl)

Base path: /api/v1/admin/rate-limits. All require Bearer access token + admin middleware.

MethodPathPermissionReturns / does
GET/VIEW_STATS{ config, lastRollups, auditLog }
GET/events?policy=&outcome=&limit=VIEW_STATSPaginated event list + total
GET/metrics?from=&to=VIEW_STATS{ rollups } filtered by dayKey range
PATCH/MANAGE_RATE_LIMITSBody { enabled?, policies? } — updates singleton + invalidates cache
POST/policiesMANAGE_RATE_LIMITSBody: full policy — upserts by id
PATCH/policies/:idMANAGE_RATE_LIMITSPartial policy fields
DELETE/policies/:idMANAGE_RATE_LIMITSRemoves policy
POST/testMANAGE_RATE_LIMITSBody { method, path } — returns which policies would match (no counters changed)

What clients see when blocked

  • Status 429 Too Many Requests
  • Headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After (seconds)
  • Body (JSON): error, code: "RATE_LIMITED", requestId, policy (policy id), retryAfterSeconds

Browsers must have these headers exposed by CORS; index.ts adds them to exposeHeaders.

Fail-open behavior

If the limiter throws (e.g. DB timeout), the middleware logs RATE_LIMITER_DEGRADED and allows the request so a broken limiter does not take down the API.

Default policies (seed values)

These are the starting policies in code (rate-limit.defaults.ts). After first DB seed, the database copy is authoritative; edit via admin UI or PATCH.

Policy idPaths / notesIdentityWindowLimitAlgorithmDefault mode
auth.login.minutePOST auth login/refreshIP60s10fixedenforce
auth.login.hoursameIP3600s100fixedenforce
users.readGET /api/v1/usersuser60s120fixedenforce
users.writemutating /api/v1/usersuser60s30fixedenforce
notifications.openPOST notification openeduser60s60fixedenforce
practice.publicpractice-pyq / practice-ytuser_or_ip60s120slidingenforce
current-affairs.publiccurrent-affairsuser_or_ip60s120slidingenforce
admin.readGET /api/v1/adminuser60s240fixedshadow
internal.notifications/internal/notificationsinternal60s600fixedshadow
global/api/v1, /internal fallbackuser_or_ip60s600fixedenforce

Tune limits in production based on monitoring and product needs. Use shadow first for anything risky, then enforce.

Code map (where to read in hono-backend)

AreaPath
Middleware mount + CORS headerssrc/index.ts
Limiter middlewaresrc/middleware/rate-limit.middleware.ts
Matching + algorithmssrc/services/rate-limit/policy-engine.ts, algorithms.ts
Storessrc/services/rate-limit/memory-store.ts, mongo-store.ts, create-store.ts
Config cachesrc/services/rate-limit/config-cache.ts
Defaultssrc/config/rate-limit.defaults.ts
Admin routessrc/routes/admin-rate-limits.routes.ts
Permission constantsrc/config/permissions.tsMANAGE_RATE_LIMITS

Kill switches (incidents)

  1. Set the noisy policy mode to off and save (super admin).
  2. Or disable globally: Disable all in Rate Limits UI (or PATCH with enabled: false).
  3. Optionally switch RATE_LIMIT_STORE and restart if the counter backend misbehaves.

Related: Architecture overview · Layers · Route map

Last updated on