# Lighter — limits: 40/60 s write window, budget, throttling, reads

What is known about Lighter limits: the hard per-L1-address write window (code 23000), how to account for it (sliding window, reserve, seeding after restart), what counts toward it, capacity in writes, and read limits. Facts were verified on the robinhoodchain instance.

## TL;DR

1. **40 writes in a sliding 60-second window per L1 address** (code **23000**, exact text: `Too Many Requests!: L1Address ratelimit reached … 40 requests per 60 second is allowed`). Writes are transactions: placement, cancellation, and `update_leverage`. Public reads do not count toward the window (50 consecutive `account?by=index` calls all returned 200). *Verified 2026-08-20.*
2. **The limit is per address, not per key or process.** Two processes on the same L1 address share one window; restarting a process does **not reset** the exchange window. *Verified 2026-08-20.*
3. **The local limit and reserve are configured explicitly.** The exchange reports only the 40/60 s ceiling; the safe margin and reserve share depend on other processes using the same L1 address and on the write profile. `lighter-kit` does not insert hidden values.
4. **A slot is consumed before the request and is not returned on failure:** a failed request may have reached the exchange. A limiter refusal is a local `rateLimited` result; no network request is sent. This is a known “not placed” outcome, so retrying on the next tick is safe.
5. **The window slides over timestamps; it is not a per-tick counter.** With a 15-second tick, four ticks fall within a 60-second window; a “per tick” budget would permit four times too many writes and produce the same 429.
6. **After restart, seed the window as exhausted** (timestamps evenly distributed over the preceding minute: the first slot opens almost immediately, the full budget after 60 seconds). A hard one-minute block would be worse because it would also delay protective orders immediately after restart, when the state of your own orders is least known.
7. **Capacity:** replacing one order costs 2 writes (cancel + place).
8. **Check a multi-write sequence against the entire window capacity before its first write**, and measure reduceOnly writes against the critical allowance. Otherwise, early steps succeed and the last one hits the window. A sequence longer than the configured `limit − reserve` allowance will not fit even in an empty window and must be split.
9. **The Python SDK turns 429 into an exception**, which the sidecar returns as an unnamed error; without recognition, a stream of such rejections after startup looks like unexplained exchange failures. Recognize code 23000 in the text.
10. **Reads have their own limit:** an initial burst of `HTTP 429` responses after restart clears within a minute. Retry reads (3 attempts, 400 ms × n); do not retry 4xx except 401/403. *Observed 2026-08.*

---

## 1. Limit map

| Limit | Value | Source | What it counts |
|---|---|---|---|
| Per-L1-address write window | 40 / sliding 60 s | error text 23000, 2026-08-20 | `create_order`, `cancel_order`, `update_leverage` (by observation, any transaction) |
| Public reads | do not count toward the write window; their own limit is unknown | 50 consecutive reads without 429; initial 429 burst on restart | `orderBookDetails`, `account`, `accountActiveOrders` |
| Auth token | 10-minute lifetime | SDK `DEFAULT_10_MIN_AUTH_EXPIRY` | cache for 5 minutes |

Not measured: the numeric read limit, whether there is a separate per-key (`api_key_index`) limit, whether there is a limit on open-order count, and whether the zkLighter-mainnet window differs.

---

## 2. Write limiter (TypeScript)

```ts
import { createWriteWindow } from '@markpaper/lighter-kit';

const writeWindow = createWriteWindow({
  limit: config.writeLimit,       // explicit application policy, no higher than the exchange ceiling of 40
  reserve: config.writeReserve,   // explicit reserve for cancellations and reduceOnly
});
writeWindow.seedAsExhausted();
```

`createWriteWindow` refuses to start without both parameters or with `limit > 40`. The 60-second window is an exchange protocol fact; a nonstandard `windowMs` is available only for explicit configuration and tests.

What counts as critical: **all cancellations** (an uncanceled order remains live and may execute against you) and **all reduceOnly orders** (protection and risk reduction). Setting leverage is an ordinary write.

Why reserve capacity: without it, ordinary placements can consume the entire budget while a protective reduceOnly order and a cancellation wait at the back of the queue—the position is open with no protection available. Without a limiter, the write stream immediately hits 23000 and becomes a stream of unnamed rejections.

---

## 3. Cost by operation

| Operation | Writes | Notes |
|---|---|---|
| Place an order | 1 | ordinary (entry) or critical (reduceOnly) |
| Cancel an order | 1 | always critical |
| Replace an order (size/price) | 2 | cancellation + placement |
| Set leverage on a market | 1 | once per market; remember “already set” |

Every repeated cancellation or new placement costs another write. Therefore, repeat frequency must be calculated against explicitly selected `limit` and `reserve` values, rather than assuming a universal cycle.

---

## 4. Throttling ≠ exchange rejection ≠ minimum

Three states that must be distinct in the placement result:

| State | Nature | Retry |
|---|---|---|
| `SKIPPED, throttled` (local window) | temporary, seconds; no network request was sent | next tick |
| `SKIPPED` due to minimum/lot | persistent until balance/position increases | pointless |
| `REJECTED` (exchange code) | known outcome | according to the code |

They cannot be conflated: if throttling is recorded as an “exchange minimum,” it is never retried even though the write would succeed seconds later; if throttling is counted as an exchange rejection, it creates a stream of false failures.

---

## 5. Reads

- Fetch timeout 15 seconds, 3 attempts, pause `400 ms × n`. A 4xx is an exchange response (do not retry); for 401/403 on authenticated reads, refresh the token and retry.
- An initial burst of 429 responses on reads after restart is normal and clears within a minute; do not count it as a failure, but do not suppress it either: if 429 persists for more than a minute, a read limit is being exceeded somewhere.
- Reads have no separate weight (or it has not been measured): plan load by request count.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| Stream of unnamed rejections after startup | 429 → SDK exception → unnamed error | limiter before sending; recognize code 23000 in the text |
| Burst after restart on top of the exchange window | in-memory counter reset, exchange window did not | seed the window as exhausted |
| Position without protection because entry orders consumed the budget | no reserve | choose and explicitly configure a reserve used only for cancellations and reduceOnly |
| “Per tick” budget overfills the window fourfold | 15-second tick, 60-second window | timestamp-based sliding window |
| Last write in a sequence hits the window | capacity was not checked before the first write | check capacity for the entire sequence in advance |
| Throttling is treated as a minimum or exchange rejection | the three states were not distinguished | `throttled` is a separate outcome retried on the next tick |

---

## Open questions / not verified

- Exact public-read limit (count, window, per IP or per address).
- Whether the write window is aggregated across all `account_index` values (sub-accounts) of an L1 address—the error text says “L1Address,” which suggests it is; not verified.
- Whether there is a separate per-key (`api_key_index`) limit.
- Whether zkLighter mainnet uses a different window and whether “premium” accounts receive higher limits.
- Whether `create_auth_token_with_expiry` counts toward the window (with a 5-minute token cache, the effect would be hard to notice).
- The maximum number of open orders per account has not been measured.

---

Facts verified through 2026-09-16. Lighter limits can change—the 23000 error text contains the number, so read it from the response rather than from this file.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license._
