ADR-021: Throttler Rate-Limit Strategy
Status as of 2026-06-06: shadow-mode observation window from 2026-05-26 has elapsed; verify enforce-flip in current config.
Date: 2026-05-26 Status: Accepted (Decision 6 from MVP P0 NFR plan) Issue: #803 (shadow mode); #805 (webhook decorators); #806 (Sentry alert); #807 (429/503 differentiation) Authors: tylerhumanity (Tom Tang)
Contextโ
#531 removed ThrottlerModule from app.module.ts (citing per-Specialist billing replacement). However, the change left five @Throttle decorators in the codebase (auth.controller.ts:49/374/385 + kb.controller.ts:81 + conversations.controller.ts:33). These decorators are metadata-only โ without a registered ThrottlerModule, they have no effect. The codebase silently dropped its only application-layer rate limit.
Under active-active multi-pod deployment (ADR-018), the absence of rate limiting creates several risk surfaces:
- DDoS on channel webhooks: Slack retry storms (#507 incident) hammer the API; no rate limit means runaway cost + queue depth.
- Brute-force on auth endpoints:
/auth/login, password reset โ uncapped. - Excessive client API usage: Single buggy client floods
/conversationswrite endpoints; impacts all tenants.
P0-9k (#743) re-registered ThrottlerModule with Redis storage for cross-pod consistency. This ADR documents the six locked sub-decisions that flesh out the strategy.
Decisionโ
Re-enable global throttling with cross-pod Redis storage, but apply it cautiously via six sub-decisions:
6.0 โ Storage library: @nest-lab/throttler-storage-redisโ
- Community-maintained, actively updated, compatible with
@nestjs/throttlerv6. - Alternatives rejected:
nestjs-throttler-storage-redis(older, less maintained); custom ioredis wrapper (NIH; reinventing rate-limit storage).
6.1 โ Strategy: per-org default with per-IP fallback for unauthenticated routesโ
OrgThrottlerGuardextractsorgIdfrom JWT (user.orgMemberships[0].orgId||user.orgId).- Falls back to
req.ipfor unauthenticated routes (channel webhooks,/auth/login). - Per-org tracking is strictly better than per-IP for our workload โ a single org's bursty traffic should not affect other orgs even if they share an IP (corporate NAT).
- Locked plan said "per-IP default" but the implementation went directly to per-org (validated as an improvement).
6.2 โ Existing five @Throttle decorators: kept at historical numeric limitsโ
auth.controller.ts:49โ 10 req/minauth.controller.ts:374โ 5 req/minauth.controller.ts:385โ 10 req/minkb.controller.ts:81โ 30 req/minconversations.controller.ts:33โ 100 req/min (per PRD ยง14)- Limits set pre-#531 by the original authors. No evidence the numbers are wrong. Adjust only after we observe Sentry shadow-mode "would-have-blocked" data.
6.3 โ Missing decorators: add to 4 channel webhooksโ
Per-channel limits sized to expected vendor retry behavior:
@Post("slack/events")โ 1000 req/min/IP@Post("email/inbound")โ 100 req/min/IP (Cloudflare batches)@Post("whatsapp/inbound")โ 1000 req/min/IP@Post("telegram/...")โ 500 req/min/IP
Out of scope for MVP: admin endpoints, file uploads, billing webhook. Adding throttle to these without empirical data risks misconfigured limits. Revisit in P1.
6.4 โ Fail mode: Fail-soft + Sentry alertโ
- When Redis storage is unavailable at module init:
ThrottlerStorageRedisServiceconstructor returns; module loads with in-memory fallback storage (per-pod). System continues to function โ rate limiting is per-pod instead of cross-pod, which is degraded but not broken. - When Redis disconnects at runtime: Sentry custom alert (
throttler.storage.disconnected) fires within 30s. Ops investigates. - Alternative rejected: hard fail-closed (return 503 for all requests during Redis outage). Service availability outweighs the brief reduction to per-pod throttling.
6.5 โ Rollout: Shadow mode 1 week โ enforceโ
- Initial deploy:
THROTTLER_SHADOW_MODE=truein dev / staging / prod. OrgThrottlerGuard.throwThrottlingException()logs[SHADOW] would-have-blockedinstead of throwing.- Operator observes Sentry logs for one week, validates no false-positive blocks on legitimate traffic patterns (Indian UAT scripted runs, integration tests, customer bursts).
- Prod flips
THROTTLER_SHADOW_MODE=false(kill switch via env var, no restart needed).
This is the safest deploy strategy for a behavior change that affects every API endpoint.
6.6 โ Boundary with backpressure (P0-9l): distinct HTTP signalingโ
| Limit type | Semantic | HTTP status | Headers | Body |
|---|---|---|---|---|
| Rate limit (9k) | "Client too fast" | 429 Too Many Requests | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset | { "code": "rate_limit", ... } |
| Backpressure (9l) | "Server overloaded" | 503 Service Unavailable | Retry-After | { "code": "backpressure", ... } |
Client retry logic must distinguish: 429 โ exponential backoff per X-RateLimit-Reset; 503 โ fixed retry per Retry-After. Documented in docs/api/CLIENT_RETRY_STRATEGY.md (planned, P1-7 follow-up).
Rejected alternativesโ
Roll our own throttler storage in ioredisโ
Rejected. ~150 LoC reimplementation of a well-tested community library. Maintenance burden outweighs the benefit of one fewer dep.
Per-IP only (no per-org)โ
Rejected for production. Multiple orgs sharing a NAT (corporate / VPN exit IP) would compete for one bucket. Per-org isolation is fundamental to multi-tenant fairness.
Hard fail-closed when Redis storage unavailableโ
Rejected. Outage in throttler storage should not become an outage in the API. Per-pod fallback storage is degraded but functional. ADR-019's "best-effort" tier explicitly covers this.
Immediate enforce (no shadow mode)โ
Rejected. Five @Throttle decorators were noop for an unknown period (between #531 and #743). Deploy day flipping them live without observation = SEV-2 risk. The shadow mode adds one week of observability for zero ongoing cost.
One throttle for all endpointsโ
Rejected. Different endpoints have different traffic profiles. /auth/login should be ~10/min (brute-force prevention). /conversations should be ~100/min (normal user activity). Channel webhooks should be ~1000/min (vendor retry storms). A global limit cannot serve all three.
Consequencesโ
Positiveโ
- Cross-pod consistency. Same client hitting different pods sees one global limit, not 2ร when LB balances.
- DDoS protection on webhooks. Slack/Twilio retry storms (real incident #507) capped at sustainable rate.
- Per-org fairness. Buggy tenant cannot exhaust shared resources.
- Observability. Sentry alerts on Redis storage outages + shadow-mode "would-have-blocked" data inform tuning.
Negativeโ
- Operational complexity. Two new env vars (
THROTTLER_SHADOW_MODE, implicitREDIS_URL). Operator must remember to flip shadow off after observation period. - Latency. Each request adds ~1ms Redis round-trip. Acceptable.
- One-time risk window. When prod enforces (flip
SHADOW_MODE=false), legitimate bursts that exceed limits will get 429. Mitigation: observe shadow-mode logs first.
Operational checklist (post-merge)โ
- Verify
THROTTLER_SHADOW_MODE=trueset in prod ECS task definition. - Sentry filter set up for
[SHADOW] would-have-blockedlog breadcrumbs. - One-week observation period scheduled.
- Flip cron / ticket created to switch
SHADOW_MODE=falseafter observation.
Reviewโ
Revisit this ADR when:
- Real prod traffic produces unexpected 429s (need to retune limits).
- We add a new endpoint that should be throttled.
- A new throttling library / storage adapter becomes available with significant advantages.
Cross-references:
- ADR-018 ยงAA3 โ idempotency / rate-limit context
- ADR-019 โ fail-soft tier rationale
docs/MVP_P0_NFR_FINAL_EN.mdยง6.0โ6.6 โ sub-decision summaries- Issue #803 โ shadow mode implementation
- Issue #805 โ channel webhook decorators
- Issue #806 โ Sentry alert
- Issue #807 โ 429/503 differentiation