Skip to content

refactor(rate-limit): limit recipients as well as requesters - #37

Closed
johnhooks wants to merge 1 commit into
mainfrom
refactor/rate-limiter
Closed

refactor(rate-limit): limit recipients as well as requesters#37
johnhooks wants to merge 1 commit into
mainfrom
refactor/rate-limiter

Conversation

@johnhooks

Copy link
Copy Markdown
Collaborator

Rate limiting rebuilt as a shared module. Requests were already throttled per endpoint; this adds the missing dimension, a cap on security email per recipient mailbox, shared across every path that can send one.

Changes

  • RateLimit\RateLimiter: fixed clock-aligned windows, counters in the object cache (atomic incr) with transient fallback, subjects hashed into keys.
  • OutboundEmailRateLimiter: one bucket for all send paths, layered 5 min / hour / day ceilings, addresses canonicalized so mailbox variants share a counter.
  • Reserve-before-send with refunds on paths that only learn from the response whether mail went out; no check-then-act gap under concurrent requests.
  • Http\ClientIp: REMOTE_ADDR unless an operator names a proxy header; list values read right-most-public.
  • Time\Clock injected everywhere; tests freeze and step it instead of using relative timestamps.
  • Limits are class constants beside the endpoints that own them. Remaining settings: the three email ceilings and WORKOS_CLIENT_IP_HEADER.

Enumeration-safe routes keep their uniform responses; refused sends are skipped silently. Details in docs/rate-limiting.md.

Rebuild throttling around two independent dimensions. HttpRateLimit
counts who is asking (per IP and per subject, fixed clock-aligned
windows); OutboundEmailRateLimiter counts who receives security email,
one bucket shared by every send path, over layered 5min/hour/day
ceilings.

- Reserve-before-send with refunds: slots are claimed before the
  WorkOS call and handed back when the response shows no mail went
  out, closing the check-then-act gap under concurrent requests.
- Addresses are canonicalized (tags, gmail dots) so variants share
  one counter, and subjects are hashed so no plaintext PII is stored.
- Counters live in the object cache (atomic incr) with a transient
  fallback; window epoch is part of the key so expiry is arithmetic.
- Client IP comes from REMOTE_ADDR unless an operator names a proxy
  header; list values are read right-most-public.
- Injected Clock (frozen in tests via a Codeception module) replaces
  time() so window rollover is testable.
- Limits are class constants beside the endpoints that own them; the
  only settings left are the email ceilings and the proxy header.
@johnhooks
johnhooks force-pushed the refactor/rate-limiter branch from 1cfe043 to 746b8c6 Compare August 4, 2026 01:41
@johnhooks

Copy link
Copy Markdown
Collaborator Author

Closed in favor of #39

@johnhooks johnhooks closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant