fix(comlink): prevent deep pagination from overloading primary DB#3362
Open
2dpetkov wants to merge 4 commits into
Open
fix(comlink): prevent deep pagination from overloading primary DB#33622dpetkov wants to merge 4 commits into
2dpetkov wants to merge 4 commits into
Conversation
Contributor
📝 WalkthroughWalkthroughPagination validation now caps computed offsets, rate limits scale with pagination depth, and paginated Comlink database reads preserve default Postgres options. Tests cover validation boundaries, rate-limit behavior, deep-page rejection, and runtime query-option propagation. ChangesPagination controls and database reads
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
ad-0xpp
approved these changes
Jul 13, 2026
Contributor
|
Tick the box to add this pull request to the merge queue (same as
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changelist
Why:
Over the weekend of 2026-07-10/11, a bot repeatedly deep-paginated
/v4/fillsand/v4/fundingPayments(page=97-111&limit=1000, i.e.OFFSET ~96,000-110,000).Root-cause: ~12 repeated block-processing/Kafka-lag pages over ~54 hours to these
OFFSET/LIMITscans landing on the RDS primary instead of a read replica, driving DB load to 1.2-2.4x vCPU capacity and starvingender's write path.A 30-day Datadog APM traffic audit (query-string
page/limitfacets onexpress.requestspans) confirmed this wasn't a one-off:live traffic on
fundingPaymentsstill sits at offset 91,000-149,000, the exact range that triggered the incident, plus separate extreme scraper tiers onfillsreaching offsets in the millions.How:
Three independent, layered mitigations:
fills,fundingPayments,trades, andtrade-historycontrollers built a partial Postgres options object ({ orderBy: [...] }) whenever they added custom pagination ordering, which — because of how the JS default parameter (options: Options = DEFAULT_POSTGRES_OPTIONS) works — silently discarded the read-replica default and fell back to the primary.Confirmed via
orders-controller.tsandhistorical-pnl-controller.ts, which already do this correctly ({ ...DEFAULT_POSTGRES_OPTIONS, orderBy: [...] }) — this is a consistency fix, not a new pattern.Also includes a lower-priority companion fix on
transfers-controller.ts's/betweenendpoint (same missing spread, but nopageparam so no OFFSET-depth exposure).getPointCostnow charges1 + floor(offset / API_LIMIT_V4)points instead of a flat 1, reusing the existingRateLimit-*headers and 429 response — deep pagination drains a client's budget fast while normal shallow pagination is unaffected.Implemented centrally (
getPointCost, shared by everyrateLimiterMiddlewarecall site) rather than per-endpoint, since the same unbounded-.offset()pattern exists in 6+ other stores (order-table,pnl-ticks-table,transfer-table, etc.) that haven't been targeted yet but are equally exposed.MAX_PAGINATION_OFFSETconfig (default 25,000), enforced via a cross-field validator onpage/limit, returning the existing400 { errors }shape with guidance to narrow the time-range filter instead of paging deeper.Value validated against real 30-day traffic: sits ~66,000 below the incident's own offset range with margin to spare, while only affecting an already-unusual slice of traffic (
limit=500, pages 40-120 onfills).Deliberately out of scope (called out for reviewers, not silently dropped): the
COUNT(*)run on every paginated query (blocked by an existing test asserting accuratetotalResultson page 2 — would be a client-facing contract change), and true cursor/keyset pagination (the real long-term fix forOFFSETcost, but a bigger API design change).What:
fix(comlink): route paginated fills/funding/trades/history reads to the read replica—fills-controller.ts,funding-payments-controller.ts,trade-history-controller.ts,trades-controller.ts,transfers-controller.ts, and their controller tests.feat(comlink): weight rate-limit cost by pagination offset depth—lib/rate-limit.tsand its test.feat(comlink): cap max pagination offset to bound query cost—config.ts,lib/validation/schemas.ts,lib/validation/helpers.ts(test harness route), andlib/validation/schemas.test.ts.Test Plan
Full
comlinkJest suite: 557/557 passing (NODE_ENV=test pnpm test, run against the dockerizedpostgres-test/rediscontainers).New coverage added: read-replica regression guards on all four fixed controllers (asserts the
findAlloptions argument is actually spread from the liveDEFAULT_POSTGRES_OPTIONSobject, independent of whateverUSE_READ_REPLICAevaluates to in the test env);getPointCostunit tests covering the weighting formula, internal-IP bypass, and adversarial/garbagepagevalues; offset-ceiling boundary tests (MAX_PAGINATION_OFFSETexactly vs. one page past it, and the default-limit case).Caught and fixed one real bug during this verification pass: the offset-ceiling validator compared
Number.isInteger()against the raw (string) query value instead of coercing first, so it never actually rejected anything — confirmed by two new tests failing 400-expected/200-actual, fixed, rebuilt, and reconfirmed.Not yet done: a live E2E check against a running
comlinkprocess (only the infra containers —postgres/postgres-test/redis/kafka— were up during this pass, not the service itself).Worth a manual
curlsmoke test against deep pagination (expect 400 past the ceiling, watchRateLimit-Remainingdrop faster with deeperpage) before merge if you want the extra confidence.Author/Reviewer Checklist
Suggest adding the
buglabel (root-cause fix for the 2026-07-10/11 weekend paging incident).Update: hardened after code review
A multi-angle review (correctness, security, test coverage, consistency) surfaced two real bugs in this PR's own new code, both now fixed:
pagequery key (?page=1&page=999999999) parses as an array in Express;Number([...])on that isNaN, and both the offset ceiling and the rate-limit weighter treated "can't parse it" as "some other validator will catch it" — butexpress-validator'sisIntonly checks an array's first element, so nothing actually did.The request went through with a
NaNoffset, which Knex silently drops, returning wrong (unpaginated) data instead of a 400.Fixed by explicitly rejecting non-scalar
pagevalues in the validator.page/limitcould produce apointCostlarge enough to serialize to exponential notation, which Redis'sINCRBYrejects — tripping an existing fail-open branch in the rate-limit middleware that silently lets the request through with zero rate limiting applied.Before this PR,
pointCostwas always 0 or 1, so this path was unreachable; fixed by clamping cost to the rate limiter's own point capacity.Also: the offset-ceiling error message no longer echoes the computed offset or the exact
MAX_PAGINATION_OFFSETvalue back to the client (kept the config name itself in the message as a debugging clue, dropped the numbers, so the ceiling can't be read straight off a 400 response).Test suite is now 565/565 passing, including new coverage for both bugs above, backfilled read-replica regression tests for
transfers-controller.tsand theparentSubaccountroute variants (previously untested), and one end-to-end test replaying a deep-pagination request through the real middleware chain against/v4/fundingPayments.Surfaced but out of scope for this PR (flagged for follow-up, not fixed here):
isIndexerIptrustscf-connecting-ip/x-forwarded-forunconditionally with no code in this repo confirming comlink's origin is locked to Cloudflare-only traffic — if it isn't, this (pre-existing, unrelated to this PR) gap bypasses rate limiting entirely, not just the new pagination-depth weighting. Worth a separate ticket to whoever owns the infra layer.Summary by CodeRabbit
New Features
Bug Fixes
Tests