Skip to content

fix(api): distributed rate limiting for the public read endpoints (GH#2487) - #2490

Open
dcccrypto wants to merge 1 commit into
playgroundfrom
fix/2487-distributed-rate-limit
Open

fix(api): distributed rate limiting for the public read endpoints (GH#2487)#2490
dcccrypto wants to merge 1 commit into
playgroundfrom
fix/2487-distributed-rate-limit

Conversation

@dcccrypto

Copy link
Copy Markdown
Owner

Fixes #2487.

Premise verified on playground@f2a3bbe5: both trader routes construct
createMemoryRateLimiter, whose state is a Map in one process. Confirmed.

A third route has the same defect

/api/stats uses the identical pattern, and its own comment already predicted
this outcome:

// Per-process only (multi-instance: effective limit = 60 × N). At mainnet
// scale, replace with Redis-backed rate limiting.

Included here rather than left to be rediscovered separately. /api/stats was
the only other createMemoryRateLimiter caller in the app — after this, none
remain outside the lib and its own unit test.

The change

All three move to createUpstashRateLimiter. As you noted, it already falls back
to in-memory when Upstash is unconfigured, so dev and CI behave exactly as
before
and only production gains the shared window.

Each route gets a distinct Redis prefix — rl:trader-trades, rl:trader-stats,
rl:stats — so one endpoint cannot spend another's budget. Limits, response
bodies, 429 headers and getClientIp-based keying are unchanged;
X-RateLimit-Remaining now comes from the single check() result rather than a
second call into the limiter.

One trap worth flagging for anyone doing this elsewhere: the new API is
async. if (rateLimiter.check(ip)) without await is a Promise — always
truthy — so the check inverts into "always limited", and the mirror slip
(if (!rateLimiter.check(ip))) silently disables the limit entirely while
looking correct. The tests assert the awaited form at each call site for exactly
that reason.

On the suggested regression test

The proposed test fires 120 real fetches at a live endpoint and expects ≥60 to
be 429. That needs a running server and a shared Redis, so in CI it would either
be skipped or assert the in-memory fallback — i.e. pass without testing the
distributed property. The property you actually care about (shared across
instances) can't be observed without Redis at all.

So the binding assertion here is the wiring: each route constructs the
distributed limiter, no longer imports the per-process one, uses a distinct
prefix, and awaits the check. That's what fails if this regresses. A test of the
limiter alone stays green with the routes reverted — which is how the
per-process version shipped in the first place.

Mutation-verified:

Mutation Result
revert one route to createMemoryRateLimiter 3 tests fail
drop the await on check() 1 test fails
cd app && npx vitest run
Test Files  282 passed | 1 skipped (283)
     Tests  2937 passed | 16 skipped (2953)

npx tsc --noEmit    # clean

Deployment note

If UPSTASH_REDIS_REST_URL / _TOKEN are not set in production, these three
endpoints keep exactly today's per-instance behaviour — the fix is inert until
Upstash is configured. #2478 (pending) makes that state alert loudly instead of
passing silently, which is the other half of making this real.

…#2487)

/api/trader/[wallet]/trades, /api/trader/[wallet]/stats and /api/stats
limited requests with createMemoryRateLimiter, whose state is a Map in
one process. On serverless that makes the effective limit
`configured x instance count` — a client spread across warm instances is
not bounded by the number on the tin, so the endpoints are open to the
scraping and profiling the limit exists to stop.

Switch all three to createUpstashRateLimiter, which shares the window
through Redis when configured and degrades to the same in-memory
behaviour when it is not — so local dev and CI are unchanged, and only
production gains the global bound.

GH#2487 names the two trader routes. /api/stats had the identical defect
and its own comment already predicted this ("Per-process only
(multi-instance: effective limit = 60 x N). At mainnet scale, replace
with Redis-backed rate limiting."), so it is included rather than left to
be rediscovered.

Each route gets a distinct Redis prefix (rl:trader-trades,
rl:trader-stats, rl:stats) so one endpoint cannot spend another's budget.
Limits, response bodies, 429 headers and getClientIp-based keying are
unchanged; X-RateLimit-Remaining now comes from the single check() result
instead of a second call.

Note the API is async: `if (rateLimiter.check(ip))` without await is a
Promise, always truthy, and would silently disable the limit. The tests
assert the awaited form at each call site for that reason.

Tests cover the wiring, not just the limiter — a limiter-only test stays
green with the routes reverted, which is how this shipped in the first
place.

Mutation-verified: reverting one route to the per-process limiter fails 3
tests; dropping the await fails 1.

Suite: 2937 passed / 0 failed. tsc --noEmit clean.
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
percolator-launch Ready Ready Preview Aug 5, 2026 4:30pm
percolator-mainnet Ready Ready Preview Aug 5, 2026 4:30pm
percolator-playground Ready Ready Preview Aug 5, 2026 4:30pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@dcccrypto, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d26e6306-42fb-4332-8d0a-21cb873401f0

📥 Commits

Reviewing files that changed from the base of the PR and between f2a3bbe and f0dfdb6.

📒 Files selected for processing (4)
  • app/__tests__/api/trader-stats-distributed-rate-limit.test.ts
  • app/app/api/stats/route.ts
  • app/app/api/trader/[wallet]/stats/route.ts
  • app/app/api/trader/[wallet]/trades/route.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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