Rate Limits and Errors
Venues limit how fast you send. Break a limit and they refuse requests; keep going and they ban your IP. ergon-io checks each request against the limits before it goes out, and tells you what became of it after.
- It never sleeps, queues or retries. You decide: wait, drop or alert.
- No venue tables in the published crate: venues change them without
notice. Binance, Bybit and OKX adapters live in the repository’s
io/examples/venues.rs.
What venues do
Checked 2026-10-17 against each venue’s docs (linked). Values change: load
them at start where the venue publishes them (Binance exchangeInfo).
Where a number is not on the linked page, the source is named in the cell.
| venue | limits | usage reported | on breach | ban |
|---|---|---|---|---|
| Binance (spot) | per IP and per account, from exchangeInfo (the docs’ own example: weight 6,000/min, raw requests 61,000/5 min, orders 50/10 s and 160,000/day — a live exchangeInfo gives yours) | X-MBX-USED-WEIGHT-1M, X-MBX-ORDER-COUNT-10S/-1D headers; WS API rateLimits | 429 + Retry-After (s); body -1003 (weight), -1015 (orders); 403: the WAF limit | sending after a 429: 418, an IP ban of 2 min to 3 days (errors) |
| Bybit (v5) | per IP: 600/5 s; per endpoint and account: a rolling 1 s limit (create order 10–20/s) | X-Bapi-Limit, X-Bapi-Limit-Status (left), X-Bapi-Limit-Reset-Timestamp | HTTP 200 with retCode 10006 (endpoint) or 10018 (IP); 429: system protection (error) | 403 “access too frequent”: IP banned at least 10 min |
| OKX (v5) | per instrument: place, cancel, amend each 60/2 s; per sub-account: 1,000 orders/2 s; batch-orders 300/2 s; options share a family | none | code 50011 (often with HTTP 200 as well as 429); 50061: sub-account | none documented |
| Coinbase (Advanced Trade) | private 30/s per user; public 10/s per IP (rate limits) | none | 429 | none documented |
| Kraken (spot) | draining counters: REST (max 15–20) and trading per pair (60–180; a quick cancel costs up to 8) | none: model it | EAPI:Rate limit exceeded, EOrder:Rate limit exceeded, EService: Throttled: <time> | a lockout of seconds, longer if you keep calling; about 150 WS connects per 10 min per IP, else 10 min |
| Deribit | credits per sub-account, refilled continuously: 20 requests/s, burst 100 (matching engine: by volume tier) | none | too_many_requests (10028), and the session ends: connect and log in again | none documented |
| Hyperliquid | per IP: weight 1,200/min; per address: 1 request per 1 USDC traded (10,000 to start) | userRateLimit info request | the address drops to 1 request per 10 s | none documented |
- One order counts against several limits at once: a Binance order against IP weight, raw requests, orders per 10 s and orders per day.
- Errors often come as HTTP 200 with a code in the body (Bybit, OKX). Classify on status and code.
- WebSocket limits count control frames (Binance spot: 5 messages/s, Pongs included) and connect attempts per IP (Binance 300/5 min, OKX 3/s).
- HTTP status is not the code. The same body code can arrive with 200, 429 or another status: read the code.
What ergon-io gives you
| piece | what it does |
|---|---|
limit::Budget | Your buckets, one per limit. try_charge takes up to 8 at once, all or none: a ticket, or “not before T”. refund gives a ticket back when the request never left. |
Model::Rolling | A window limit (Binance, Bybit, OKX, connect attempts). Never more than the limit in any window of window + guard, whatever window edges the venue uses. guard_ms defaults to one sub-window; set it to at least your send-to-venue jitter. |
Model::Decay | A draining counter (Kraken, Deribit, Coinbase Exchange). Not for window limits: it lets twice the limit through in one window. |
reserve, Class::Reduce | Units only cancels and reduce-only orders may use, so you can always cancel. |
observe_used, set_limit | What the venue’s headers say: raise the local count, never lower it. |
throttled, banned | A 429 holds the bucket for the venue’s wait plus the guard; with no wait it also marks the bucket full for one window. A ban halts it until clear_halt. |
try_resend, Budget::resend | Re-send a request that did not run, only inside its own deadline. Budget::resend takes the verdict: Unknown answers Reconcile, never re-send. |
outcome::Sent | For a failed request: No (never left; safe to send again) or Maybe (may have run). In ClientHandler::on_error. |
outcome::Verdict, generic | What became of a response: done, rejected, not run, throttled, banned, clock skew, unknown. generic uses the status alone; your adapter reads the body code first. |
retry_after_ms, header_u64, ms_until_epoch | Header parsing with no allocation. A malformed header is an error, never 0. |
| connect gate | ClientHandler::connect_gate (HTTP pool) and Reconnect::poll_gated (WebSocket): every connect passes a bucket first (WebSocket). The gate lives on the handler, so it shares the handler’s budget. |
WsStats::auto_frames | Frames the connection sent by itself (Pongs): charge them with Budget::force. |
One order, start to end
A Binance adapter: limits at start, the charge before sending, the verdict after:
// At start: Binance's limits from `GET /api/v3/exchangeInfo`.
let limits = binance::limits(example::BINANCE_EXCHANGE_INFO.as_bytes())?;
let (b, mut budget) = binance::Binance::new(limits)?;
// Before sending: every bucket the order counts against, or none.
let ticket = budget.try_charge(b.new_order()?.as_slice(), Class::Normal, T0)?;
// On its response (`on` replays a recorded one): usage, then the verdict.
let v = on(example::BINANCE, "order-ack", |r| {
b.on_response(r, &mut budget, NOW)
})??;
What it reads in a response:
match r.status {
// Bans (403: the WAF limit, 418): the IP scope stops until a
// person clears it. Before any header: a ban always halts.
// The gate every request and every connect charges, so one
// ban stops them all.
403 | 418 => return super::ban(r, self.ip, BAN_MIN_MS, budget, now),
_ => {}
}
let code = match super::json_body(r)? {
Some(body) if r.status >= 400 => {
body.get("code")?.map(json::Value::as_i64).transpose()?
}
_ => None,
};
// 409 cancel-replace: -2022 ran nothing (both halves failed);
// -2021 may have run one half.
if r.status == 409 {
return Ok(match code {
Some(-2022) => Verdict::NotRun,
_ => Verdict::Unknown,
});
}
let verdict = match code {
Some(-1003) => {
// "Too many requests queued." is a queue depth, not the
// weight window: filling the weight bucket would stop
// everything on the IP for a whole window. The queued
// variant holds the raw-requests scope, else a short hold
// on weight that does not fill it.
if super::contains_ignore_case(r.body, b"queued") {
if let Some(raw) = self.raw {
super::throttle(r, raw, budget, now)
} else {
let wait = outcome::retry_after_ms(r, super::utc_ms(now))?.or(Some(0));
let until_ns = budget.throttled(self.weight, wait, now.ns)?;
Ok(Verdict::Throttled {
bucket: self.weight,
until_ns,
})
}
} else {
super::throttle(r, self.weight, budget, now)
}
}
Some(-1015) => self.too_many_orders(r, budget, now),
Some(-1021) => Ok(Verdict::ClockSkew),
// -1006, -1007 and -1008: -1006/-1007 lost track of the
// request; spot -1008 is only "server is currently
// overloaded" and does not say the order failed. All three
// mean it may have run.
Some(-1008..=-1006) => Ok(Verdict::Unknown),
// Status alone: 2xx done, 429 throttled, 4xx rejected, 5xx unknown.
_ => outcome::generic(r, self.weight, budget, now),
}?;
// Server truth, after the hold or halt stands: other senders on
// this IP or account show up here. A malformed usage header is
// reported but does not undo the hold.
for (name, bucket) in &self.usage {
if let Some(used) = super::usage(r, name)? {
budget.observe_used(*bucket, used, now.ns)?;
}
}
Ok(verdict)
What to do next with the order:
/// The re-send rules: only what did not run, through the budget, inside
/// its own deadline; never an order that may have run.
///
/// [`Budget::resend`] makes that last rule structural: it answers
/// `Reconcile` for an unknown outcome instead of a re-send.
#[must_use]
pub fn next(order: &Order, verdict: Verdict, budget: &mut Budget, now_ns: i64) -> Next {
match verdict {
Verdict::Done => Next::Applied,
Verdict::Rejected => Next::Dropped,
Verdict::Banned { bucket, .. } => Next::Halted(bucket),
_ => match budget.resend(
verdict,
order.parts.as_slice(),
order.class,
order.deadline_ns,
now_ns,
) {
Resend::Send(ticket) => Next::SendNow(ticket),
Resend::Wait(limited) => Next::RetryAt(limited.ready_at_ns),
Resend::Stale(_) => Next::Dropped,
Resend::Reconcile => Next::Reconcile(order.client_order_id),
},
}
}
(From io/examples/venues.rs and io/tests/example_venues.rs, which
cargo test -p ergon-io runs on every platform against responses written
from each venue’s docs, in io/tests/fixtures/venues/.
cargo run -p ergon-io --example venues prints each decision.)
Over the wire, one order with the pooled client:
try_charge→ keep theTicketby request id.HttpClient::requestreturns at once; the answer comes toClientHandler::on_responseoron_error.on_response→ the adapter’son_responseabove, thenBudget::resendfor the verdict.on_error(request, sent, error)→Sent::No:refundthe ticket andBudget::resend;Sent::Maybe:Unknown, reconcile by client order id.
Error policy
| what came back | verdict | ran? | what to do |
|---|---|---|---|
| 2xx, no error code | Done | yes | apply it |
| 400, 401, 404, other 4xx; 3xx | Rejected | no | fix it; never send it again unchanged |
| 408 | Unknown | maybe | look it up by client order id |
| 429, Binance -1003/-1015, Bybit 10006/10018, OKX 50011/50061 | Throttled | no | the bucket the error names is held; send again only after the hold, inside its deadline |
| Binance 418 or 403, Bybit 403 (any) | Banned | no | the IP stops until a person clears it; alert |
| Binance -1021, Bybit 10002, OKX 50102 | ClockSkew | no | send again with a new timestamp, inside its deadline |
| Binance -1008 (overloaded), -1006/-1007 | Unknown | maybe | look it up by client order id |
| 409 with Binance -2022 (both halves failed) | NotRun | no | send again, inside its deadline |
| 5xx, Binance 409 with -2021, Bybit 10000/10016, OKX 50004 | Unknown | maybe | look it up by client order id |
no response, Sent::No | NotRun | no | refund the ticket; send again, inside its deadline |
no response, Sent::Maybe (timeout, reset) | Unknown | maybe | look it up by client order id |
- A timeout is not a free re-send. A 408, and a Binance -1007
(“timeout waiting for response from backend server”), are
Unknown: the backend may have taken the order. Re-send only what the venue says did not run (a Binance -2022), never what might have. - The code decides before the status. On a 409 the body code picks
NotRun(-2022) orUnknown(-2021). - Hold the narrowest bucket the error names. A -1015 for 10 s holds the 10 s order bucket; cancels (not orders) still go.
- A hold blocks cancels too. On Binance a request sent into a 429 risks a ban. Arm the venue’s cancel-on-disconnect before trading: it is the only cancel path during a hold.
- Refund only
Sent::NoandBusy. Venues count rejected and throttled requests too. - A server wait is the server’s answer. A 429 with
Retry-Afterholds for that wait plus the guard; the bucket keeps its count. With no wait, the bucket is marked full for one window (the local count was wrong). - A ClockSkew that repeats is the host clock. Re-signing with a new timestamp fixes one; if they keep coming, the clock is off: fix the host clock (chrony; see Clocks), not the re-send.
Rules
- A ban stops sending until a person clears it.
Budget::bannedhalts the scope. The end of the venue’s wait does not resume it:Budget::clear_halt, called by a person’s action, does.Budget::halted()andBucketStats::haltedshow it, so the loop can alert. Halt an IP-scope gate that every request and every connect charges, so one ban stops them all. The minimum waits differ: Binance 418 is 2 minutes (to 3 days for repeats); Bybit 403 is 10 minutes. A second ban costs more than a pause. - One sender per account and IP. The budget sees only this process. Budgets are not shared between processes.
- Send again only what did not run, and only inside its deadline
(
Throttled,ClockSkew,NotRun). The application sets each order’s deadline (a few ms, say).Budget::try_resendcharges it again, or says when to ask, or calls it stale. - Never send an order with an unknown outcome again. Every order carries a unique client order id; look it up by that id (or watch the user data stream). Only Coinbase Advanced Trade returns the existing order for a repeated id.
- libcurl takes no method but GET, HEAD, OPTIONS and DELETE unless you opt in (below).
A ban, step by step:
let v = on(example::BINANCE, "ban-418", |r| {
b.on_response(r, &mut budget, NOW)
})??;
// Retry-After 300 s. The IP gate has no window to align to, so no
// guard: this is the end of the server's wait. The halt does not end
// there; a person clears it.
assert_eq!(
v,
Verdict::Banned {
bucket: b.ip(),
until_ns: T0 + 300 * S
}
);
// Ten minutes later the server's wait is long over: still stopped.
let later = T0 + 600 * S;
assert_eq!(why(&mut budget, &b.request(1)?, later), Some(Why::Banned));
// Every connect is stopped too: it charges the same gate.
assert_eq!(
why(&mut budget, &b.connect(), later),
Some(Why::Banned),
"a banned IP must not reconnect"
);
// The loop's alert check names it.
let mut alerted = Vec::new();
example::alerts(&budget, later, |name, stats| {
alerted.push((name.to_owned(), stats.bans));
})?;
assert_eq!(alerted, [("ip".to_owned(), 1)]);
// A person decides; then sending resumes.
budget.clear_halt(b.ip())?;
assert_eq!(why(&mut budget, &b.request(1)?, later), None);
assert_eq!(why(&mut budget, &b.connect(), later), None);
What it cannot do
- See other senders. The venue counts every process, host behind the same NAT and script on the same key or IP. Usage headers show them late (one round trip) and only in part; Kraken, Coinbase, Deribit and OKX send none. So run one sending process per account and IP. Put bulk REST on another IP, or in the same process and budget.
- Know a WebSocket request’s fate when its connection drops: ergon-io does not know WebSocket request ids. Treat pending ones as unknown.
- Retry or queue. Only the application knows whether a request is still wanted, and it must sign it again with a new timestamp.
libcurl sends requests twice
- What happens: when a reused connection dies before any reply byte,
libcurl sends the request again, POST included, up to 5 times
(
Curl_retry_requestinlib/transfer.c, curl 8.22.0). No option turns this off. An order that ran just before the connection died runs twice. - What ergon-io does: the libcurl
HttpClientrefuses every method but GET, HEAD, OPTIONS and DELETE, withError::CurlResend, unlesscurl_allow_non_idempotent_methodsis set.io/tests/client.rsshows libcurl re-sending, and the refusal. - Send orders through the native client: it never re-sends.
Cost
try_charge, the check before each request, is on the send path. Its
cost is not measured here: no committed run holds it. Measure it with
io/benches limit_bench:
cargo build --release --manifest-path io/benches/Cargo.toml --bin limit_bench
io/benches/target/release/limit_bench --samples 200000 --batch 64 # Linux: --cpu <isolated cpu>
- The bench reports ns per call (the mean of a
--batch-call batch, not a per-call percentile), the slowest batch, stalls andallocs(allocations in the timed loop; expect 0). - Catch-up walk. A ring stepped just under one full turn walks every
slot instead of taking the
fill(0)path a longer idle would.Budget::tickduring idle keeps this off the send path: the send path only ever pays for the slots since the last charge. - macOS cannot pin or isolate the thread; a quiet max needs an isolated Linux CPU.
- Inside ergon-io the send path adds one store per request and one add per
send(forSent), and one add per automatic WebSocket frame.