[BUG] Binance WS subscription quota uses Quota::per_second(5) (max_burst=5) — guarantees 1008 'Too many requests' on cold start
Description
On a cold start with a large instrument universe, the Binance adapter's WebSocket subscription
messages violate the venue's per-connection rate limit within the first second. Binance closes the
connection with 1008 / 'Too many requests'; the client reconnects, immediately re-subscribes
(WebSocket reconnected, restoring subscriptions → subs.all_topics()), and is closed again —
a reconnect storm that only ends when the venue's penalty window expires (~2 minutes).
This is not a missing limiter: the limiter is on the send path
(send_subscribe() → send_text(..., BINANCE_RATE_LIMIT_KEY_SUBSCRIPTION)), but the configured
quota Quota::per_second(5) is a token bucket with max_burst = 5, i.e. 5 messages instantly,
then one every 200 ms → up to 9 messages inside the first rolling second. Binance's limit is a
strict rolling window of 5 incoming messages per second per connection.
Environment
nautilus_trader2.0.0rc5 (release wheel; also checked ondevelop, commit1b0a49d)- Binance USDⓈ-M Futures, testnet;
BinanceDataClientConfig(product_type=USD_M, environment=TESTNET) - Reproduced with both
transport_backend=SOCKUDO(default) andTUNGSTENITE - Reproduced with a direct connection and through a local HTTP proxy
- Python 3.12, Linux x86_64
Steps to reproduce (with NautilusTrader)
Subscribe 215 symbols × (bars + funding rates) = 430 subscription commands in one on_start():
for sym in symbols: # 215 symbols
self.subscribe_bars(bar_type[sym])
self.subscribe_funding_rates(iid[sym])Observed (stdout=Debug):
[WARN] nautilus_network::websocket::client: Received close frame, terminating:
code=1008, reason='Too many requests' × 54 … 61 per run
[INFO] nautilus_binance::futures::websocket::streams::client:
WebSocket reconnected, restoring subscriptions × 54 … 61
[ERROR] ...futures::websocket::streams::handler:
Failed to send subscribe request: send failed: timeout waiting for active stateThe number of 1008 closes tracks the number of subscription messages
(measured: 75 commands → 9 closes, 215 → 27, 430 → 54–61).
Minimal reproduction without NautilusTrader (decisive)
The venue rule can be isolated with a raw socket, no credentials required:
import asyncio, json, websockets
async def main():
async with websockets.connect("wss://stream.binancefuture.com/ws",
ping_interval=None) as ws:
# exactly what Quota::per_second(5) emits: burst of 5, then one per 200 ms
for i in range(5):
await ws.send(json.dumps({"method": "SUBSCRIBE",
"params": ["btcusdt@kline_1m"], "id": i + 1}))
for i in range(20):
await asyncio.sleep(0.2)
await ws.send(json.dumps({"method": "SUBSCRIBE",
"params": ["ethusdt@kline_1m"], "id": i + 6}))
await asyncio.sleep(3)
asyncio.run(main())
# → websockets.exceptions.ConnectionClosedError:
# received 1008 (policy violation) Too many requestsMeasured matrix (each case on a fresh connection, 4 s settle):
| # | pattern | messages | streams | result |
|---|---|---|---|---|
| 1 | one message, 1 stream | 1 | 1 | ✅ clean |
| 2 | one message, 5 streams | 1 | 5 | ✅ clean |
| 3 | one message, 6 streams | 1 | 6 | ✅ clean |
| 4 | one message, 50 streams | 1 | 50 | ✅ clean |
| 5 | 5 messages, no gap | 5 | 5 | ✅ clean (all acked) |
| 6 | 20 messages, 300 ms apart | 15 | 15 | ✅ clean |
| 7 | 6 messages, no gap | 6 | 6 | ⚠️ no close, but only 5 acks — excess silently dropped |
| 8 | 10 messages, no gap | 10 | 10 | ⚠️ no close, but only 5 acks |
| 9 | steady 2.53 msg/s | 13 | 13 | ✅ clean |
| 10 | burst 5, then 1 per 200 ms | 6+ | 6+ | ❌ closed with 1008 after 0.67 s |
| 11 | steady 6.7 msg/s | 6 | 6 | ❌ closed with 1008 |
| 12 | steady 10 msg/s | 7 | 7 | ❌ closed with 1008 |
Two conclusions that matter for adapter design:
- The limit is on messages, not streams — a single
SUBSCRIBEcarrying 50 streams is fine. - A one-off burst above 5 is silently dropped (no close frame), so a subscription can end up silently incomplete; only a sustained rate above 5/s triggers the close.
Suggested fix
crates/adapters/binance/src/common/consts.rs:
// before: max_burst = 5, replenish 200 ms → up to 9 messages in the first rolling second
pub static BINANCE_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant")
});
// after: single-cell quota → at most 4 messages in any rolling second
pub const BINANCE_WS_SUBSCRIPTION_MIN_INTERVAL_MS: u64 = 300;
pub static BINANCE_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
Quota::with_period(Duration::from_millis(BINANCE_WS_SUBSCRIPTION_MIN_INTERVAL_MS))
.expect("valid constant")
});Note that Quota::per_second(4) would not be a fix: it still sets max_burst = 4
(4 instantly + one per 250 ms = 8 in the first rolling second). A burst-1 quota is required.
Measured effect of the patch (locally built wheel, 215 symbols / 430 commands, no app-level pacing)
| rc5 | patched (with_period(300ms)) |
|
|---|---|---|
1008 closes on cold start |
60 | 0 |
Failed to send subscribe request |
42 | 0 |
| WS state transitions | 124 | 4 (the 4 startup connects — zero reconnects) |
| bars per symbol over 5 min | median 4 of 5 expected | median 7 of 6 expected |
| SUBSCRIBE messages | 63, incl. one 200-stream message | 429, all single-stream, evenly spaced |
Wire-level verification of the patched build (temporary debug! in
WebSocketClientInner::send_text, because release_max_level_debug removes all trace! calls):
messages sent : 430
bytes : min=58 median=64 max=73 (all single-stream)
keyed_rate_limit : True=430 / False=0
limiter_wait_ms : min=0 median=299 p90=300 max=301
└ zero-wait messages : 3 (= exactly one per connection: the initial cell)
bursts (<20 ms clusters) : 430 × size-1 ⇒ zero bursting
max messages in any 1 s : 12 globally (3 connections ⇒ ≈4 per connection; limit is 5)
1008 closes : 0Also: the limit is per connection, not per IP (measured)
Subscriptions are sharded across connections (Pool slot 1/2 connected, 200 streams per slot) and
each connection gets its own keyed quota. Concurrent raw connections (burst 2, then one message per
300 ms each):
| connections | aggregate rate | result |
|---|---|---|
| 1 | 3.25 msg/s | ✅ clean, all 130 acked |
| 2 | 6.70 msg/s | ✅ clean, all 268 acked |
| 3 | 10.03 msg/s | ✅ clean, all 401 acked |
⇒ the per-connection limiter design is correctly shaped; only the quota semantics need fixing.
Suggested refinement: coalesce and pace
With the fix above, 430 subscriptions are sent as 430 single-stream messages, so the subscription
ramp takes ~60 s. Since the venue counts messages, not streams, it would be better to keep
batching (the current code already produces e.g. one 200-stream message) and pace the messages:
e.g. one message per 300 ms carrying up to N streams ⇒ 430 streams subscribed within a couple of
seconds with zero 1008.
Related defects found while investigating
log::trace!is compiled out of the release wheel.Cargo.tomlenables thelogcrate'srelease_max_level_debugfeature, so everytrace!innautilus_network(includingSending text frame ({} bytes)incrates/network/src/websocket/client.rs) is statically removed in release builds. Wire-level investigation currently requires a debug build — this is not documented.- Module-level log filters cannot raise verbosity.
Logger::enabled()(crates/common/src/logging/logger.rs) gates onstdout_level/fileout_levelbeforefilter_policy.should_skip()runs, sostdout=Info;nautilus_network::websocket=Traceis a no-op by construction. Thefrom_specdoc example (my_crate::module=Debug) implies otherwise. from_specputs keys without::intocomponent_level, which is matched by exact equality (crates/common/src/logging/config.rs), sonautilus_network=Tracematches nothing at all. Together with (2) this makes the obvious configuration silently useless.LoggerConfig.__new__has nomodule_levelsparameter, so aLoggerConfigrebuilt in Python silently drops all module filters produced byfrom_spec.- The reconnect buffer bypasses the keyed quota.
drain_reconnect_buffer()(crates/network/src/websocket/client.rs) writes withwriter.send(...)directly, withoutawait_rate_limit_or_closed(keys), whilesend_text()does apply it. Messages buffered during a disconnect are therefore replayed unmetered. - Reconnect re-subscribe is unconditional and full: every reconnect re-sends
subs.all_topics(). Combined with a venue penalty window this forms the storm loop; a capped backoff for re-subscription would make the client well-behaved on its own.
Workaround available today (no patch)
Pace at the actor level with clock.set_timer, one subscription action per ≥330 ms
(note one symbol = 2 actions for bars + funding rates). Measured: 1008 61 → 1,
Failed to send subscribe request 40 → 0, missing 1-minute bars 12 → 0. It does not reach the
adapter's internal re-subscribe, hence the residual single closure.
Source: nautechsystems/nautilus_trader