Delphi 中的 Binance 速率限制:批量订阅、节流与可读的 429 | eSeGeCe 博客

Delphi 中的 Binance 速率限制:批量订阅、节流与可读的 429

· 组件

每一家交易所都会公布速率限制,也都会强制执行。对于触发限速的 REST 调用,Binance 会返回 HTTP 429 以及一个你需要遵守的 Retry-After 头;如果某个 WebSocket 连接每秒发送超过 5 条消息,它就会关闭这个连接。两份客户报告在几天之内先后到达,各自对应其中的一半,合起来暴露出三个值得认真修复的问题。

这三个问题现在都已修复。其中一个的影响范围远远超出了 Binance。

失败的请求过去会把响应头丢掉

当通过某个现成的 API 客户端发出的请求以 HTTP 错误状态失败时,组件会抛出一个携带状态码、状态文本和响应体的异常。响应头则不见了。不是被隐藏,而是彻底消失。异常还在沿调用栈向上传递时,HTTP 对象就已经被销毁,响应头也随之消失。

这使得无法在组件之上构建符合 Binance 规范的退避逻辑。Retry-After 会准确告诉你需要等待多久,忽略它会让 429 升级为 HTTP 418 以及临时的 IP 封禁。Binance 返回的限速计数器,例如 X-MBX-USED-WEIGHT-1M,同样无法获取,因此应用程序也无法在撞上限制之前自行调整节奏。

现在抛出的异常是 EsgcHTTPAPIProtocolException,它会一并携带响应头。它继承自之前抛出的那个异常,因此已有的处理代码仍然可以捕获它,除非你想使用这些新信息,否则无需做任何改动。

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 返回以毫秒为单位的延迟,并且能识别该头允许的两种形式:纯粹的秒数,以及 HTTP 日期。当该头不存在时,它返回 -1。这项修复适用于全部八个请求方法,而不只是 GetPostQuery 原本就接受的 ResponseHeaders 参数,现在在请求失败时也会被填充,而那正是你最需要它的时候。

与此相关,还有一点值得了解:自 2026.7.0 起,HTTP 客户端也可以替你完成等待。

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

一个帧,而不是十二个

Binance 的 WebSocket 协议允许在单个 SUBSCRIBE 帧中传入一组数据流列表。组件此前并没有利用这一点。每个 Subscribe* 辅助方法都会立即写出一个恰好只包含一个数据流的帧,因此一个普通的自选列表,六个交易对乘以两个频道,会在几毫秒之内发送十二个帧。Binance 关闭了连接,与文档描述的完全一致。

现在提供了批量方法,可以把整个列表作为一个帧发送:

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

即使有数百个数据流,一个帧也远低于 8 KB 的请求上限,因此在实践中一个自选列表始终只需一个帧。UnsubscribeStreams 则是与之对应的反向操作。

谁也绕不过去的那一部分

这是最要紧的一个问题,而两份报告都没有完全指出它。

每个交易所 API 都会保存你订阅过的频道,以便在重连之后恢复它们。这个重放过程为每个数据流连续发送一个帧,完全没有任何节流,而目标连接才刚刚建立几毫秒。只要自选列表有一点实际规模,这个新连接就会立刻被关闭。随后 WatchDog 重新连接,再次重放同样的突发流量,然后又被关闭。一次短暂的断线就这样变成了永久的重连循环。

由于这一突发流量是在组件内部产生的,应用程序无法规避它。自己编写帧而不使用辅助方法也无济于事,因为重放并不是由你来调用的。

对库的其余部分进行审查后发现,同样的重放循环被复制到了 17 个交易所 API 中:Binance、Bitfinex、Bitget、Bitmex、Bitstamp、Bybit、Cex、CexPlus、Coinbase、CryptoCom、Deribit、GateIO、Huobi、Kraken、Kucoin、MEXC 和 OKX。每一个都在冲撞各自交易所的限制。

现在 WebSocket API 客户端上提供了 Throttle 选项:

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

这里的默认值值得解释一下,因为它是刻意不对称的。Throttle.EnabledFalse,因此除非你主动要求,你的应用程序所发出调用的时序不会有任何改变。但 Throttle.PaceResubscribeTrue,因此重连后的重放开箱即用地受到节流。

道理很简单。你的订阅循环由你自己掌控,也可以自行批量处理,因此那里是否节流由你决定。重放不由你掌控,所以由组件替你节流。这样一来,所有现有应用程序都不必改动任何代码,重连循环的问题就已经解决。在 Binance 上,重放同样以合并帧的方式发送,因此一个包含十二个数据流的自选列表现在只需重放一个帧,而不是十二个。如果你想要之前的行为,把 PaceResubscribe 设为 False

如果你同时使用多家交易所,还有一个细节:每秒 4 条消息的默认额度适合 Binance,其文档中的限制是 5。OKX 文档中的限制是 3,因此 OKX 客户端的默认值就是 3。在调高 MaxMessages 之前,请先确认你所用交易所的限制。

升级

可以直接替换。新异常继承自之前抛出的那个异常,批量方法是新增的,而 Throttle 对所有情况都默认关闭,唯一的例外就是那个此前会破坏重连的重放。请从 sgcWebSockets 下载页面下载最新版本。

这两份报告都来自愿意花时间阅读源码、复现问题并把它准确写清楚的客户。第三个也是最严重的一个缺陷正是因此被发现的。如果组件中有什么行为让你无法解释,请告诉我们。

有疑问、反馈或需要迁移方面的帮助?联系我们,回复你的将是编写这些代码的人。