Skip to content

P0: Add per-IP request rate limiting (#166)#185

Open
dkijania wants to merge 1 commit into
mainfrom
feat/rate-limit
Open

P0: Add per-IP request rate limiting (#166)#185
dkijania wants to merge 1 commit into
mainfrom
feat/rate-limit

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

Part of the production-readiness epic (#163). Closes #166.

A public GraphQL endpoint with no throttle lets a single client monopolise the server and the backing Postgres. This adds a global, per-client-IP rate limiter that runs on every request before GraphQL parsing, rejecting over-limit traffic with HTTP 429 as cheaply as possible.

Env var Default Meaning
RATE_LIMIT_MAX 600 Requests per client IP per window; 0 disables
RATE_LIMIT_WINDOW_MS 60000 Window length in ms

Design

  • Client IP from X-Forwarded-For (first hop) → X-Real-IP → socket address → shared unknown bucket. Run behind a proxy that sets X-Forwarded-For for correct per-client identification (documented).
  • Fixed-window in-memory counter, per-instance: with N replicas the effective limit is ~N × RATE_LIMIT_MAX. A shared store (Redis) for exact cross-replica limits is left as deployment hardening and noted in the docs.
  • Health checks (/healthcheck) are never throttled.
  • 429 responses carry Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining.

Why not @envelop/rate-limiter

That library is directive-based: limits are baked into the schema per field and it's per-field rather than a global per-IP bucket, plus it requires a schema/codegen change. A small custom plugin gives a true global per-IP DoS bucket that's env-tunable with no schema impact. (Discussed and chosen during implementation.)

Testing

  • npm run build — clean
  • npm run test:unit — all pass (config parsing; windowing/reset/prune with an injected clock; end-to-end through Yoga proving the (max+1)th request returns 429 and other clients are unaffected)
  • npm run lint — clean
  • npx prettier --debug-check . — exit 0

No new runtime dependency.

🤖 Generated with Claude Code

A public GraphQL endpoint with no throttle lets a single client monopolise the
server and the backing Postgres. Add a global, per-client-IP rate limiter that
runs on every request before GraphQL parsing, rejecting over-limit traffic with
HTTP 429 as cheaply as possible.

- RATE_LIMIT_MAX        (requests per client per window, default 600; 0 disables)
- RATE_LIMIT_WINDOW_MS  (window length in ms, default 60000)

The client IP is taken from X-Forwarded-For (first hop), then X-Real-IP, then
the socket address, falling back to a shared `unknown` bucket so unproxied
traffic is still bounded. Health checks are never throttled. The fixed-window
counter is in-memory and per-instance; a shared store for exact cross-replica
limits is left as deployment hardening and noted in the docs.

Implemented as a custom plugin rather than @envelop/rate-limiter, whose
directive model bakes limits into the schema and is per-field rather than a
global per-IP bucket. Malformed env values fall back to safe defaults. Unit
tests cover config parsing, the windowing/reset/prune logic with an injected
clock, and prove end-to-end through Yoga that the (max+1)th request from an IP
gets 429 while other clients are unaffected.

Closes #166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
@dkijania dkijania added production-readiness Work toward making the API production-ready / publicly available P0 Blocker for public availability labels Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P0 Blocker for public availability production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P0: Add rate limiting (edge or in-process)

1 participant