Skip to content

feat(client): surface Poly-RateLimit state on rate-limit errors and via onRateLimitUpdate - #254

Open
kartojal wants to merge 5 commits into
mainfrom
feature/dev-480-unified-ts-sdk-clob-rate-limit-headers-poly-ratelimit-retry
Open

feat(client): surface Poly-RateLimit state on rate-limit errors and via onRateLimitUpdate#254
kartojal wants to merge 5 commits into
mainfrom
feature/dev-480-unified-ts-sdk-clob-rate-limit-headers-poly-ratelimit-retry

Conversation

@kartojal

@kartojal kartojal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Implements DEV-480.

  • RateLimitError now carries the Poly-RateLimit-* state reported with a 429 rejection (rateLimit field), alongside the existing retryAfter.
  • New onRateLimitUpdate client option: a listener invoked whenever a response reports per-signer rate-limit state (remaining, reset, tier, warning), including warning-mode monitoring before live enforcement.

https://linear.app/polymarket/issue/DEV-480


Note

Low Risk
Additive API and response handling with isolated listener error swallowing; no changes to auth or request signing.

Overview
Surfaces Poly-RateLimit-* response headers in @polymarket/client so integrators can react before hard 429s.

RateLimitError now includes an optional rateLimit object (alongside retryAfter) when a 429 response carries those headers. A new rate-limit module parses Remaining, Reset, Tier, and Warning, with strict integer validation and optional order / cancel bucket tagging for known CLOB order and cancel paths.

ServiceClient invokes an optional onRateLimitUpdate listener on any response that reports rate-limit state (success or failure); listener errors cannot affect request handling. createPublicClient / createSecureClient accept onRateLimitUpdate and pass it through to CLOB ServiceClient instances (including secure CLOB), preserving it after endAuthentication.

Reviewed by Cursor Bugbot for commit 4081d5c. Bugbot is set up for automated code reviews on this repo. Configure here.

@kartojal
kartojal marked this pull request as ready for review August 12, 2026 11:01

@brunson-bot brunson-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the whole diff plus the emitting side (polymarket/clob-v2 packages/trading-gateway, main @ d75daff70). The wiring is right: clob + secureClob are the only service clients that can ever see these headers (only the trading gateway sets them), and the listener survives upgrade (clients.ts:499) and logout (clients.ts:768). Header semantics check out against the gateway — Poly-RateLimit-Reset really is now + ResetTime in unix seconds (api.go:268), Remaining really can go negative for standard tiers via the post-cancel debit (api.go:305, rate_limiter.go Debit), Poly-RateLimit-Warning is only ever set to "true" and only in warn mode when the request would have been blocked (api.go:270-273), and the headers are attached before the 429 is written, so RateLimitError.rateLimit is populated. minor bump is right, all CI green.

3 [issue] + 2 [nit] inline, nothing blocking. The one I'd fix before merge is the async-listener case: the documented "errors are ignored" guarantee doesn't hold for async listeners.

);
}

#notifyRateLimitUpdate(update: RateLimitUpdate): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[issue] The try/catch only covers synchronous throws, but RateLimitUpdateListener accepts async listeners: (update) => Promise<void> is assignable to (update) => void (verified, tsc 5.9.3). So onRateLimitUpdate: async (update) => { await metrics.report(update) } with a failing report produces an unhandled rejection — process-fatal under Node's default --unhandled-rejections=throw (verified: exit 1). That contradicts the comment on the next line and the onRateLimitUpdate TSDoc at clients.ts:869 ("Errors thrown by the listener are ignored").

#notifyRateLimitUpdate(update: RateLimitUpdate): void {
  try {
    void Promise.resolve(this.#onRateLimitUpdate?.(update)).catch(() => {});
  } catch {
    // A consumer-provided listener must never affect request handling.
  }
}

The ignores rate-limit listener errors test only covers the sync throw, so this stays uncovered.

* the request was evaluated; `warning` is `false` whenever the response does
* not report warning mode.
*/
export type RateLimitUpdate = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[issue] The update carries no scope, but the gateway keeps two independent buckets per signerrate_limit:{signer}:order and rate_limit:{signer}:cancel (rate_limiter.go:79), with separate rates/bursts and separate Remaining, both reported through the same header names (api.go:266). Both flow through the same ServiceClient: posts hit secureClob.post('/order'|'/orders') (actions/orders/post.ts:85,123) and cancels hit secureClob.del('/order'|'/orders'|'/cancel-all'|'/cancel-market-orders') (actions/orders/cancel.ts:83,129,165,212).

So a consumer gets one interleaved stream of remaining values from two buckets with no way to tell them apart — remaining: 3 after a cancel says nothing about order capacity, and the documented use ("monitor remaining capacity … adjust request patterns") can't be implemented correctly. Path alone isn't enough to disambiguate downstream either (POST /order vs DELETE /order are the same URL, and Response doesn't carry the method), so it needs to come from the SDK side: #toResult is called from #request (ServiceClient.ts:138) where both method and path are in hand — thread them through and expose either the request context or a derived bucket: 'order' | 'cancel' on the update.

* request was accounted for. Can be negative for tiers that allow a
* negative cancellation balance.
*/
remaining?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[issue] remaining: 0 does not imply exhaustion. handleRateLimit emits the header set whenever mode isn't disabled and Redis didn't error (api.go:259-268), but two paths return a zero-valued result with Allowed: true: an exempt client IP (rate_limiter.go:62-64) and a tier whose rate or burst is unconfigured (:72-74). Both ship Poly-RateLimit-Remaining: 0 with Reset: now. A consumer following the TSDoc guidance would throttle itself to a standstill while actually being unlimited — and the unconfigured-rates case is exactly the rollout state this feature is meant to observe. Worth either documenting the caveat here or having the gateway skip the headers on those two paths.

Comment thread packages/client/src/rate-limit.ts Outdated
* Per-signer rate-limit state reported by order and cancellation responses.
*
* @remarks
* Fields mirror the `Poly-RateLimit-*` response headers. Each optional field

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] "any subset can be present" is broader than the emitter: Remaining, Tier and Reset are always set together (api.go:266-268), and the post-cancel debit re-sets Remaining + Reset on the same response (api.go:305-306), so the only realistic absence is Tier (empty on the exempt path) or a value that fails to parse. Keeping the fields optional at a wire boundary is fine; the sentence just invites more defensive branching than the platform requires.

Comment thread packages/client/src/rate-limit.ts Outdated
return undefined;
}

const parsed = Number(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Number() accepts '1.5', '1e3' and '0x10', while #parseRetryAfterHeader in ServiceClient.ts:273 guards the sibling header with /^\d+$/. Both values are strconv.Itoa/FormatInt server-side, so /^-?\d+$/ here would match the existing convention and keep remaining an integer (the - is needed for the negative cancel balance).

@mcamara mcamara left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

brunson's comments are right, but aside from that, LGTM

@brunson-bot brunson-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 (10e0f27 merge + 4081d5c). All five round-1 findings are closed:

  • async listener errorsvoid Promise.resolve(...).catch(...) inside the existing try covers both quadrants (a sync throw propagates out of the argument expression before Promise.resolve runs, so the catch block still catches it), and both are now tested. The TSDoc at clients.ts:900 was updated to say returned rejections are ignored too.
  • bucket ambiguitybucket: 'order' | 'cancel' now rides on the update and on RateLimitError.rateLimit. Re-verified the mapping against clob-v2 main (packages/trading-gateway/pkg/api/api.go:84-89): the six routes that call handleRateLimit are exactly POST /order, POST /orders (api.go:404, :539, order bucket) and DELETE /order|/orders|/cancel-all|/cancel-market-orders (:649, :698, :773, :856, cancel bucket). The SDK's table matches all six with nothing missing and nothing extra, and the Redis key is rate_limit:{signer}:{bucket} (rate_limiter.go:88) — makers only pick the tier (GetPostUserTier), so "per-signer" in the docs is accurate.
  • remaining: 0 ≠ exhausted — documented on the field, with the "do not back off solely because this value is zero" line. That covers both zero-valued-but-allowed paths (rate_limiter.go:69-71 unlimited IP, :78-80 unconfigured rate/burst).
  • "any subset can be present" and Number() laxity — reworded, and parseIntegerHeader now takes /^-?\d+$/ for remaining (negative cancel balances) and /^\d+$/ for reset, plus Number.isSafeInteger.

The 10e0f27 merge had a genuine conflict in ServiceClient.test.ts (both sides appended tests at the same spot). Resolution is correct — 11 base + 4 from this branch + 4 from #248 = 19 test titles, none dropped, only deviation from the auto-merge tree is the conflict markers and the brace placement they broke.

Also checked and not a finding: ky.create({ throwHttpErrors: false }) means a 429 never becomes an HTTPError, so ky's default retry (which includes DELETE and status 429) never fires and the listener can't double-report a single cancel.

All CI green. 1 [issue] + 1 [nit] inline, neither blocking.

return path.startsWith('/') ? path.slice(1) : path;
}

#rateLimitBucket(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[issue] This puts the CLOB trading-gateway route table inside the service-agnostic transport, four days after #248 took the opposite direction in this same file: 1b55dd0 ("scope trading restrictions to order actions") pulled the 425 / post_only_mode interpretation out of ServiceClient into actions/orders/restrictions.ts, review then removed even the per-request mapRejectedResponse hook (52fb561), and the test that locks it in — does not apply service-specific policy to rejected responses, ServiceClient.test.ts:483 — now sits ~130 lines below the new bucket tests.

The six literals here are a second copy of paths the order actions own: post.ts:86,125 and cancel.ts:84,130,166,213, which already tag those exact calls with .mapErr(mapTradingRestrictionError). Nothing ties the two lists together, so renaming a path in cancel.ts silently degrades bucket to undefined with every test still green. A rateLimitBucket?: RateLimitBucket on the request options, set at those six call sites, keeps the tag as data passing through the transport rather than route knowledge living in it.

I asked for the bucket in round 1 and pointed at #request, so the location is as much my suggestion as your diff — the value is right, it's only ownership I'd reconsider. Non-blocking; leaving it here is defensible if you'd rather not reintroduce a per-request option this soon after #248 removed one.

/**
* Listener invoked whenever a response reports per-signer rate-limit state.
*/
export type RateLimitUpdateListener = (update: RateLimitUpdate) => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The "rejections from returned promises are ignored" contract is documented on PublicClientOptions.onRateLimitUpdate (clients.ts:900-902) but not here, and this is the symbol a consumer hovers while writing the listener. AGENTS.md asks types to describe their own contract; one line would help, since a => void return reads as sync-only even though 4081d5c deliberately supports and tests promise-returning listeners.

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.

3 participants