knowledge/nado/rate-limits.md
vregistry-c914171 · 8 KB
# Nado — request limits: weights, budgets, local throttling, retries
How to calculate load on the Nado gateway, which limits are published, and how to configure a local throttle. All exchange limits here are **from documentation** (docs.nado.xyz, developer-resources/api/rate-limits): they were not verified by hitting the limit live. Headroom, burst, and concurrency depend on the workload and are configured by the calling application.
## TL;DR
1. **Two documented limit families:** queries — **2400 weight/min per IP** (burst 400 per 10 s); executes — **600/min per wallet** (burst 100 per 10 s). A second wallet expands only the execute budget; a second IP expands only the query budget. → §1
2. **Local throttle configuration is mandatory:** sustained rate and burst are configured separately for queries and executes, along with shared HTTP concurrency. `nado-kit` does not insert author-selected values. → §3
3. **The expensive query is `orders`: weight 2 per product id.** Chunk size must be explicit and keep request weight within the configured query burst; otherwise, the request is impossible by construction. → §4
4. **Executes are counted separately:** replacing an order costs 2 executes (cancel + place), and rejected repeats also consume budget. Action frequency must be calculated against the explicitly selected execute rate. → §5
5. **Retry by error class:** do not retry a failure envelope; retry 429; retry transport/timeout only for idempotent operations. Every request has a 10-second timeout. → §6
---
## 1. Documented limits
| Limit | Key | Value | Burst |
|---|---|---|---|
| queries | IP | 2400 weight/min | 400 / 10 s |
| executes | wallet | 600 weight/min | 100 / 10 s |
Not verified live: which HTTP code and body are returned when exceeded (presumably 429), window length, whether executes are counted against the master wallet or the signer, and whether multiple sub-accounts under one address share an execute budget.
---
## 2. Request weights
Query weights are from documentation (developer-resources/api/rate-limits), as encoded in the local throttle. Execute weights (1 for `place_order` / `cancel_orders`, 30 for `link_signer`) are the values encoded in the throttle; they were not independently checked against documentation.
| Weight | Query / execute |
|---|---|
| 1 | `contracts`, `order`, `market_price` (one product); execute `place_order`, `cancel_orders` |
| 2 | `symbols`, `subaccount_info`, `nonces`, `subaccount_orders`; `orders` — **2 per product id**; `market_prices` — ≈ 1 per product id |
| 5 | `linked_signer`, `all_products` |
| 30 | execute `link_signer` (in the local throttle) |
The weight of `fee_rates` is not recorded.
---
## 3. Local throttle
Mechanics: token bucket by weight + concurrency semaphore + retry by error class (§6), with separate buckets for queries and executes. `createWeightThrottle` requires five explicit parameters: `queriesPerMinute`, `queryBurst`, `executesPerMinute`, `executeBurst`, `maxConcurrent`.
Rules:
- Give the execute queue high priority so background reads do not delay a cancellation or close.
- Periodically flush labeled call counters (`orders`, `subaccount_info`, `order:close`, …) to logs: this is the only way to see which subsystem consumes the budget.
- The selected sustained rate and burst must account for documented ceilings, other processes using the same IP/wallet, and the exchange’s unknown actual window.
**A local throttle refusal ≠ an exchange rejection.** Conflating them suppresses real exchange rejections and produces false alarms that clear by themselves seconds later. A local-window refusal is temporary (it clears within seconds); an exchange minimum rejection is permanent. Their statuses must be distinct.
---
## 4. Cost of one tick
For one sub-account with a working set of N products and a positions → orders → positions fence:
| Operation | Weight | Notes |
|---|---|---|
| `subaccount_info` × 2 | 4 | fence |
| `orders` for the working set | 2 × N | explicit chunk size that fits within query burst |
| `market_prices` for the working set | ≈ N | mids |
| `symbols` | 2 per request | frequency is controlled by the caller’s cache |
| complete `orders` survey | 2 × product count | only when a completeness consumer exists |
| IOC fill: `subaccount_info` before and up to 3 after | 2 + 2..6 | per IOC |
Total per-minute consumption is the weight of one pass multiplied by pass frequency. Compare it with explicit configuration, not with a universal “tick budget.”
**Bucket-capacity trap.** A request whose weight exceeds the configured capacity can never obtain tokens. The implementation rejects it with `RangeError`; therefore, `chunkProductIds` and `readOpenOrders` require an explicit chunk size.
---
## 5. Executes: where the budget leaks
- Replacing one order costs 2 executes (cancel + place).
- If your resting orders for an asset are canceled before an IOC on that asset (for example, a TP that could reverse the position because resting reduceOnly does not exist, `orders.md` §3), the IOC also incurs the cancellations and subsequent replacement.
- **Closing on a `post_only` market:** IOC is rejected with 2117; repeated attempts continue consuming execute budget. Retry only after a fresh market-mode read, and keep its frequency within the configured execute rate.
- **Nado has no separate hard write window beyond the execute limit.** Still calculate the action cost before the first cancellation.
---
## 6. Retries
| Class | Retry | Why |
|---|---|---|
| failure envelope with `error_code` | no | the sequencer responded and the order was not applied; each code has its own branch |
| HTTP 429 | yes, backoff + jitter | rejected before matching |
| 5xx, timeout (10 s), disconnect, non-JSON | query — yes; execute — only idempotent operations (`cancel_orders`, full reduceOnly close) | outcome is unknown: an entry or partial reduction may have been applied |
An execute is marked with an idempotency flag when sent; the retry wrapper decides from that flag. Retrying a non-idempotent execute after a timeout risks a double position.
---
## 7. Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| An `orders` request is rejected locally | its weight exceeds the configured bucket capacity | choose an explicit chunk size that fits within query burst |
| Read budget is consumed by mids | `market_prices` for all markets on every tick | use the working set; query the entire universe only when the set is empty |
| A close waits behind background reads | one priority | execute is high priority; the semaphore admits it first |
| False “orders are not being placed” alarm that clears seconds later | a local throttle refusal was treated as an exchange rejection | distinct statuses for a local throttle refusal and an exchange minimum rejection |
| Execute budget is consumed on weekends | close rejected with 2117 is retried every tick | calculate cost before acting; make a second close attempt only after a fresh exchange signal |
---
## 8. Open questions / not verified
- **All exchange-limit numbers come from documentation:** 2400/min per IP and 600/min per wallet were not verified by hitting the limit live; rejection code, body, and window were not captured.
- **Which wallet executes are counted against**—the master or linked signer; whether sub-accounts under one address share a budget.
- **`orders` weight** = 2 × product count is encoded from documentation; actual exchange accounting was not verified.
- **Direct hosts:** whether their limits are separate or shared with the gateway is unknown.
- **WebSocket** as a polling replacement, if available, has not been studied; if one appears, recalculate §4.
- **Actual labeled consumption** was not measured: the numbers in §4 are calculations from weights.
---
Facts verified through 2026-09-16. Nado limits come from documentation; recheck them and rejection shapes with live requests.
---
<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Nado knowledge base” and link to the original and the license._