Delphi での Binance レート制限: バッチ送信、ペース調整、読み取れる 429 | eSeGeCe ブログ

Delphi での Binance レート制限: バッチ送信、ペース調整、読み取れる 429

· コンポーネント

どの取引所もレート制限を公開しており、どの取引所もそれを実際に適用します。Binance はレート制限に達した REST 呼び出しに HTTP 429 と、尊重することが求められる Retry-After ヘッダーで応答し、1 秒あたり 5 メッセージを超えて送信してくる WebSocket 接続を閉じます。この 2 つの側面についてそれぞれ 1 件ずつ、数日のうちにお客様から報告が届き、両者を合わせると、きちんと修正する価値のある 3 つの問題が明らかになりました。

3 つとも修正済みです。そのうちの 1 つは、Binance をはるかに超える範囲に影響していることが判明しました。

失敗したリクエストはヘッダーを捨てていた

用意された API クライアントのいずれかを経由して行ったリクエストが HTTP エラーステータスで失敗すると、コンポーネントはステータスコード、ステータステキスト、ボディを保持する例外を発生させていました。レスポンスヘッダーは失われていました。隠れていたのではなく、消えていたのです。例外がまだスタックを上っている途中で HTTP オブジェクトが破棄され、ヘッダーも一緒に失われていました。

そのため、このコンポーネントの上に Binance に準拠したバックオフを構築することは不可能でした。Retry-After はどれだけ待つべきかを正確に示しており、これを無視すると 429 から HTTP 418 へ、そして一時的な IP 禁止へとエスカレートします。X-MBX-USED-WEIGHT-1M のように Binance が返すレート制限のカウンターも同様に取得できなかったため、アプリケーションは壁にぶつかる前に自らペースを調整することもできませんでした。

発生する例外は 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 は遅延をミリ秒で返し、このヘッダーが取り得る 2 つの形式、つまり単純な秒数と HTTP 日付の両方を解釈します。ヘッダーが存在しない場合は -1 を返します。この修正は Get だけでなく 8 つのリクエストメソッドすべてに適用され、PostQuery がすでに受け取っていた ResponseHeaders パラメーターは、リクエストが失敗したとき、つまりまさにそれが必要なときに値が設定されるようになりました。

あわせて知っておくとよいこと。2026.7.0 以降、HTTP クライアントが待機処理を代わりに行うこともできます。

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

12 個ではなく 1 個のフレームで

Binance の WebSocket プロトコルは、1 つの SUBSCRIBE フレームでストリームのリストを受け付けます。コンポーネントはそれを利用していませんでした。Subscribe* ヘルパーはいずれも、ストリームをちょうど 1 つだけ含むフレームを即座に書き込んでいたため、2 つのチャンネルにまたがる 6 銘柄というごく普通のウォッチリストでも、数ミリ秒のうちに 12 個のフレームを送信していました。Binance はドキュメントに書かれているとおり、接続を閉じました。

現在は、リスト全体を 1 つのフレームとして送信するバッチ送信メソッドが用意されています。

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

ストリームが数百あってもフレームは 8 KB のリクエスト上限をはるかに下回るため、実際にはウォッチリストは常に 1 つのフレームに収まります。UnsubscribeStreams はその対になるメソッドです。

誰も回避できなかった部分

これが最も重要な問題であり、どちらの報告もこれを完全には捉えていませんでした。

どの取引所 API も、再接続後に復元できるように、購読したチャンネルを保持しています。この再購読は、ペース調整をまったく行わずにストリームごとに 1 つのフレームを立て続けに、確立してから数ミリ秒しか経っていない接続へ送信していました。現実的な大きさのウォッチリストであれば、新しい接続はすぐに閉じられました。その後 WatchDog が再接続し、同じバーストを送り直し、また閉じられました。一時的な切断が 1 回起きただけで、恒久的な再接続ループに変わっていたのです。

このバーストはコンポーネントの内部で生成されるため、アプリケーション側では回避できませんでした。再購読は自分で呼び出すものではないので、ヘルパーを使わずに自分でフレームを書いても解決しませんでした。

ライブラリの他の部分を監査したところ、同じ再購読ループが 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 では再購読も結合されたフレームとして送信されるため、12 ストリームのウォッチリストは 12 個ではなく 1 つのフレームとして送り直されるようになりました。以前の動作を望む場合は PaceResubscribeFalse に設定してください。

複数の取引所を使う場合の細かな注意点。1 秒あたり 4 メッセージという既定の上限は、ドキュメント上の制限が 5 である Binance に合わせたものです。OKX はドキュメントで 3 としているため、OKX クライアントの既定値は 3 です。MaxMessages を引き上げる前に、利用する取引所の制限を確認してください。

アップグレード

そのまま差し替えられます。新しい例外は以前発生していた例外を継承しており、バッチ送信メソッドは追加されたものです。Throttle は、再接続を壊していた再購読を除いて既定でオフになっています。最新バージョンは sgcWebSockets のダウンロードページからダウンロードしてください。

この 2 件はいずれも、時間をかけてソースを読み、問題を再現し、正確に書き起こしてくださったお客様から寄せられたものです。そのおかげで 3 つ目の、最もやっかいなバグが見つかりました。コンポーネントに説明のつかない動作を見つけたら、ぜひお知らせください。

ご質問、ご意見、移行のお手伝いが必要でしょうか。お問い合わせください。コードを書いた本人から返信が届きます。