Binance Rate Limits in Delphi: Batching, Pacing and Readable 429s | eSeGeCe Blog

Binance Rate Limits in Delphi: Batching, Pacing and Readable 429s

· Components
Binance Rate Limits in Delphi: Batching, Pacing and Readable 429s

Every exchange publishes rate limits, and every exchange enforces them. Binance answers a rate-limited REST call with an HTTP 429 and a Retry-After header you are expected to honour, and it closes a WebSocket connection that sends it more than 5 messages per second. Two customer reports arrived within days of each other, one about each half of that, and between them they exposed three problems worth fixing properly.

All three are fixed now. One of them turned out to affect far more than Binance.

A failed request used to throw its headers away

When a request made through one of the ready-made API clients failed with an HTTP error status, the component raised an exception carrying the status code, the status text and the body. The response headers were gone. Not hidden, gone. The HTTP object was destroyed while the exception was still travelling up the stack, and the headers went with it.

That makes a compliant Binance backoff impossible to build on top of the component. Retry-After tells you exactly how long to wait, and ignoring it escalates from a 429 to an HTTP 418 and a temporary IP ban. The rate-limit counters Binance returns, such as X-MBX-USED-WEIGHT-1M, were equally unreachable, so an application could not pace itself before hitting the wall either.

The exception raised is now EsgcHTTPAPIProtocolException, and it carries the response headers with it. It descends from the exception raised before, so existing handlers keep catching it and nothing needs to change unless you want the new information.

try
  vResponse := oBinance.GetAggregateTrades('BTCUSDT');
except
  on E: EsgcHTTPAPIProtocolException do
  begin
    if E.ErrorCode = 429 then
      Sleep(E.RetryAfterMs);
    vWeight := E.GetHeader('X-MBX-USED-WEIGHT-1M');
    // E.ResponseHeaders holds the complete list
  end;
end;

RetryAfterMs returns the delay in milliseconds and understands both forms the header is allowed to take, a plain number of seconds and an HTTP date. It returns -1 when the header is not there. The fix applies to all eight request methods, not only Get, and the ResponseHeaders parameter that Post and Query already accepted is now filled when a request fails, which is precisely when you need it.

Worth knowing alongside this: since 2026.7.0 the HTTP client can also do the waiting for you.

oBinance.RetryOptions.Enabled := True;
oBinance.RetryOptions.MaxRetries := 3;
// HonorRetryAfter is already True by default

One frame instead of twelve

The Binance WebSocket protocol accepts a list of streams in a single SUBSCRIBE frame. The component was not using it. Every Subscribe* helper wrote one frame carrying exactly one stream, immediately, so an ordinary watchlist of six symbols across two channels sent twelve frames within a few milliseconds. Binance closed the connection, exactly as documented.

There are now batching methods that send the whole list as one frame:

oBinance.SubscribeStreams(['btcusdt@aggTrade', 'btcusdt@depth',
  'ethusdt@aggTrade', 'ethusdt@depth']);

A frame stays far below the 8 KB request limit even with hundreds of streams, so in practice a watchlist is always one frame. UnsubscribeStreams is the mirror image.

The part nobody could work around

This is the one that mattered most, and neither report fully caught it.

Every exchange API keeps the channels you subscribed to, so it can restore them after a reconnect. That replay sent one frame per stream, back to back, with no pacing at all, onto a connection that was a few milliseconds old. A watchlist of any realistic size got the fresh connection closed immediately. The WatchDog then reconnected, replayed the same burst, and got closed again. A single transient disconnect turned into a permanent reconnect loop.

Because that burst is generated inside the component, an application could not avoid it. Writing your own frames instead of using the helpers did not help, since the replay is not something you call.

Auditing the rest of the library showed the same replay loop had been copied into 17 exchange APIs: Binance, Bitfinex, Bitget, Bitmex, Bitstamp, Bybit, Cex, CexPlus, Coinbase, CryptoCom, Deribit, GateIO, Huobi, Kraken, Kucoin, MEXC and OKX. Each one against its own exchange's limit.

There is now a Throttle option on the WebSocket API clients:

oBinance.Throttle.Enabled := True;      // paces your own Subscribe calls
oBinance.Throttle.MaxMessages := 4;     // default, leaves room for PING/PONG
oBinance.Throttle.IntervalMs := 1000;   // default

The default deserves an explanation, because it is deliberately asymmetric. Throttle.Enabled is False, so the timing of the calls your application makes does not change unless you ask for it. But Throttle.PaceResubscribe is True, so the reconnect replay is paced out of the box.

The reasoning is simple. You control your own subscribe loop and can batch it, so pacing there is your decision. You do not control the replay, so it is paced for you. The reconnect loop is fixed for every existing application with no code change at all. On Binance the replay is also sent as combined frames, so a twelve-stream watchlist now replays as a single frame rather than twelve. Set PaceResubscribe to False if you want the previous behaviour.

One detail if you use several exchanges: the default budget of 4 messages per second suits Binance, whose documented limit is 5. OKX documents 3, so the OKX client defaults to 3. Check your exchange's limit before raising MaxMessages.

Upgrading

Drop-in. The new exception descends from the one raised before, the batching methods are additions, and Throttle is off by default for everything except the replay that was breaking reconnects. Download the latest version from the sgcWebSockets download page.

Both of these came from customers who took the time to read the source, reproduce the problem and write it up precisely. That is how the third and worst bug got found. If something in the components behaves in a way you cannot explain, tell us.

Questions, feedback or migration help? Get in touch, you will get a reply from the people who wrote the code.