improve(binance): never retry a rate-limit response - #3767
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9cd809fe13
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Retry-After is in seconds and is the value Binance wants us to honour. | ||
| const retryAfter = Number(response?.headers?.get?.("retry-after")); | ||
| const retryAfterS = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : BINANCE_DEFAULT_BACKOFF_S; | ||
| return { retryAfterS: Math.min(retryAfterS, BINANCE_MAX_BACKOFF_S), banned }; |
There was a problem hiding this comment.
Preserve the full Retry-After in the shared cooldown
When Binance returns a Retry-After greater than 60 seconds—particularly for an HTTP 418 ban—this truncates the server's deadline before publishing it through setBinanceCooldown. The current request is rejected, but another caller or the next run in the same process resumes after only 60 seconds, while the IP is still banned, which can prolong the ban. Cap an individual caller's sleep or reject it via the backoff budget without shortening the shared cooldown deadline.
Useful? React with 👍 / 👎.
| } | ||
| const waitS = cooldownS + Math.random() * BINANCE_COOLDOWN_JITTER_S; | ||
| backoffBudgetS -= waitS; | ||
| await delay(waitS); |
There was a problem hiding this comment.
Recheck the shared cooldown after sleeping
With concurrent callers, the earliest waiter can wake, receive another 429, and extend binanceCooldownUntilMs while the remaining callers are still sleeping. Those callers proceed directly from this await to fn() using the stale deadline, so they issue a burst during the newly extended cooldown and can escalate the throttle into an IP ban. Re-evaluate the shared deadline after each sleep before making the request.
Useful? React with 👍 / 👎.
| const status = response?.status; | ||
| const banned = status === BINANCE_IP_BAN_STATUS; | ||
| const limited = | ||
| banned || status === BINANCE_RATE_LIMIT_STATUS || (typeof code === "number" && BINANCE_RATE_LIMIT_CODES.has(code)); |
There was a problem hiding this comment.
Preserve HTTP status when classifying SDK errors
For Binance's direct JSON error responses, binance-api-node@0.12.7 constructs an error from the response body's code and msg without retaining the Fetch Response, so an actual HTTP 418 commonly reaches this parser as only code: -1003. This classifies the ban as an ordinary retryable throttle, waits for the 30-second fallback, and calls Binance again—the behavior this change is intended to prevent. Preserve the HTTP status and headers at the client boundary, or otherwise ensure a body-only -1003 cannot cause an HTTP 418 to be retried.
Useful? React with 👍 / 👎.
| // Publish the cooldown even when giving up, so siblings and the next run still back off. | ||
| setBinanceCooldown(rateLimit.retryAfterS); |
There was a problem hiding this comment.
Apply the shared cooldown to every Binance request
Publishing the cooldown here only affects calls routed through retryBinanceRequest, but the same module still sends accountCoins, tradeFee, depositAddress, allOrders, order, withdrawal, and withdrawal-quota requests directly; the rebalancer also retries order-book requests independently. If a wrapped history request establishes a cooldown and a subsequent run or sibling path invokes one of those endpoints, it contacts Binance during the cooldown and can extend the throttle or ban. Make the cooldown a client-wide request gate rather than applying it only to the three history/trade readers.
Useful? React with 👍 / 👎.
|
I've used my 1 automated round on this PR, so I'm ignoring further automated-reviewer activity. To re-activate me: have a human @-mention |
|
All four P1s were real. Fixed in 1. Indistinguishable — so 2. Truncating the shared cooldown. Agreed, and it compounded #1 — the 60s cap was applied to the value being published, so even a correctly-detected ban would release every caller after a minute. Per-sleep capping and the shared deadline are now separate: sleeps happen in ≤60s slices, the cooldown carries Binance's real deadline. Kept a 1h ceiling so a malformed deadline can't wedge the process indefinitely; past that we re-probe and get re-banned if still out. 3. Waking against a stale deadline. Correct — the wait was a single 4. Cooldown only covering three readers. Right, and there were two client construction sites, not one — One thing that came out of composing #4 with the retry loop: a gate refusal ( Verification — Known looseness, not fixed: a gated client method called inside Also unchanged from the original description: retries are still silent (no logger in That's the single automated Codex round used up. The four findings were well-targeted — if you want another pass over this, assigning me to the PR raises the budget. |
Binance rate limits were retried like any other failure: three attempts roughly one, two and four seconds apart. Retrying a throttle that quickly is what escalates it into an HTTP 418 IP ban. binance-api-node discards the HTTP status and headers whenever the response body is JSON, which it is for these responses, so Retry-After is unreadable and a ban cannot be told apart from an ordinary throttle. Treat any rate-limit signal as terminal for the call instead; the callers are all scheduled and run again on their own. Co-Authored-By: Claude <noreply@anthropic.com>
f4778ad to
25a8958
Compare
|
|
||
| /** @returns true if `err` is a Binance rate-limit response, which must not be retried. */ | ||
| export function isBinanceRateLimitError(err: unknown): boolean { | ||
| const { code, response } = (err ?? {}) as { code?: unknown; response?: { status?: number } }; |
There was a problem hiding this comment.
Do better - don't just assert types like this. Use superstruct.
There was a problem hiding this comment.
Done in 90bf1d62.
const BinanceApiErrorSS = type({ code: number() });
const BinanceHttpErrorSS = type({ response: type({ status: number() }) });
export function isBinanceRateLimitError(err: unknown): boolean {
if (is(err, BinanceApiErrorSS) && BINANCE_RATE_LIMIT_CODES.has(err.code)) {
return true;
}
return is(err, BinanceHttpErrorSS) && BINANCE_RATE_LIMIT_STATUSES.has(err.response.status);
}Two structs matched independently rather than one struct of optional fields. That turned out to matter: with a single type({ code: optional(number()), response: ... }), a malformed code fails validation for the whole error and takes the valid response.status down with it. So { code: "-1003", response: { status: 429 } } would read as not-rate-limited and get retried — which the assertion also got wrong, since it short-circuited on typeof code === "number" before looking at the status. Both shapes now stand on their own, with a test pinning it.
type() over object() so message/stack/url are ignored rather than rejected — same reasoning as the comment on WithdrawalQuotaSS in BinanceClient.ts.
Also added coverage for the shape binance-api-node actually throws (code set on an Error instance, not a plain object) and for wrong-typed signals. 30 tests pass in BinanceUtils, 62 across the other Binance suites; tsc and lint clean.
Replace the `as` assertion on the caught error with superstruct structs. The two shapes are matched independently rather than through one struct of optional fields, so a malformed `code` cannot mask a valid `response.status` and vice versa -- a case the assertion got wrong and which is now covered by a test. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks — addressed in Swapped the Worth flagging one thing that came out of it: I used two structs matched independently rather than a single struct of optional fields. With one struct, a malformed Also added coverage for the shape
One note for the record: this branch was force-pushed to |
Binance rate-limit responses are currently retried like any other failure.
getBinanceWithdrawals/getBinanceDeposits/getBinanceFillTradescall the SDK's genericretry(fn, 3, 2), whose delays are2 ** i + random— roughly 1s, 2s, 4s. So a throttle gets three more requests inside ~7 seconds, which is what escalates it into an HTTP418IP ban.The change: don't retry a rate-limit response.
isBinanceRateLimitErrormatches error code-1003/-1015, or HTTP status429/418. Non-rate-limit failures keep exactly their current retry behaviour.Why not honour
Retry-Afterand sleep instead? Because we can't read it.binance-api-node@0.12.7builds the error from the response body when that body is JSON — which it is for rate-limit responses — and keeps onlycode, discarding theResponseand its headers:Two consequences:
Retry-Afteris unavailable on the responses that matter, and an HTTP418ban arrives looking identical to a429throttle. So sleeping-then-retrying would be guesswork that risks retrying a ban. Not calling again is both simpler and stricter. The429/418status checks are kept for the non-JSON proxy-error path, where the status is retained.The trade-off is that a throttled request fails its run rather than recovering in-place. These three are history/balance reads on scheduled bots, so the next run picks them up — and failing fast also keeps a throttled dependency from stretching a run past its own interval.
Verification —
yarn tsc --noEmit, eslint and prettier clean; 52 Binance tests pass (BinanceUtils,BinanceClient,BinanceFinalizer,BinanceAdapter.withdrawals,BinanceStablecoinSwapBridge). Four new unit tests cover both error shapes, the negative cases (-1021clock skew,502), that a rate limit is not retried, and that ordinary failures still are.Not addressed here
.catch(), so request volume during an outage is invisible.BinanceUtilshas no logger to thread; separate change.Supersedes the first version of this PR, which added a shared cross-caller cooldown, a backoff budget and an error class. Codex flagged three P1s that were all artefacts of that cooldown, plus the
Retry-After/418problem above — dropping the cooldown removes the first three and the client behaviour rules out the fourth.