src/writeBudget/window.ts
v0.2.1 · 6.2 KB
// Sliding write window per L1 address (knowledge base: rate-limits.md §1-2).
//
// The exchange allows 40 write transactions (create_order, cancel_order, update_leverage) per sliding
// 60 s PER L1 ADDRESS, error code 23000. Reads are outside the window. Two processes on one address
// share it; a process restart does not reset it.
//
// The caller must choose its own limit below the exchange ceiling and an explicit reserve for
// CRITICAL writes (all cancels and all reduceOnly orders). They are workload policy, not exchange
// facts, so this package deliberately has no tuned defaults for them.
//
// The window is sliding by timestamps, not a per-tick counter: several loop iterations fall into one
// 60 s window, so a per-iteration budget can overflow it several times over. A slot is taken BEFORE
// the request and never returned: a failed request may have reached the exchange.
//
// After a restart the window is seeded as if the previous process had spent the budget evenly over
// the last minute: the first slot frees almost immediately, the full budget after 60 s. A hard block
// for a minute would also delay protective orders right when the book is least known.
/** Exchange limit: 40 writes per 60 s per L1 address (from the 23000 error text, 2026-08-20). */
export const EXCHANGE_WRITE_LIMIT = 40;
export const EXCHANGE_WRITE_WINDOW_MS = 60_000;
export interface WriteBudget {
/** Writes in the window right now. */
used: number;
limit: number;
reserve: number;
/** Slots an ORDINARY write may still take (`limit - reserve - used`, floored at 0). */
freeOrdinary: number;
/** Slots a CRITICAL write may still take (`limit - used`, floored at 0). */
freeCritical: number;
}
export type TakeSlotResult = { ok: true; used: number } | { ok: false; rateLimited: true; error: string; used: number };
export interface WriteWindowOptions {
/** Caller-selected limit. Must not exceed the exchange's 40-write ceiling. */
limit: number;
/** Default {@link EXCHANGE_WRITE_WINDOW_MS}. */
windowMs?: number;
/** Caller-selected slots that only critical writes may use. */
reserve: number;
/** Clock, for tests. */
now?: () => number;
}
export interface WriteWindow {
/**
* Takes a slot BEFORE a write. `critical` = cancel or reduceOnly order (whole limit); otherwise the
* ordinary allowance `limit - reserve`. A refusal is a LOCAL decision: nothing was sent, a retry on
* the next tick is safe.
*/
tryTake(critical: boolean, now?: number): TakeSlotResult;
/** Occupancy for logs and health. */
budget(now?: number): WriteBudget;
/**
* Seeds the window as fully spent, freeing evenly over the coming window ("the previous process
* used the budget exactly"). Call once at startup.
*/
seedAsExhausted(now?: number): void;
/** Replays known write timestamps (e.g. from a journal) into the window; older than the window are ignored. */
seedFromRecentWrites(timestamps: Iterable<number>, now?: number): void;
/** Timestamps currently in the window (copy). */
stamps(now?: number): number[];
reset(): void;
}
/**
* Timestamps of a fully spent window, spread evenly over the past `windowMs` so slots free one by one.
* Pure helper for tests and restart recovery.
*/
export function seedWriteWindow(limit: number, now: number, windowMs = EXCHANGE_WRITE_WINDOW_MS): number[] {
const n = Math.max(0, Math.floor(limit));
return Array.from({ length: n }, (_, i) => now - windowMs + Math.round(((i + 1) * windowMs) / (n + 1)));
}
export function createWriteWindow(opts: WriteWindowOptions): WriteWindow {
if (!opts || opts.limit === undefined || opts.reserve === undefined) {
throw new TypeError('createWriteWindow requires explicit limit and reserve');
}
const limit = Math.floor(opts.limit);
const windowMs = Math.floor(opts.windowMs ?? EXCHANGE_WRITE_WINDOW_MS);
const reserve = Math.floor(opts.reserve);
if (!(limit >= 1)) throw new RangeError(`limit must be >= 1, got ${String(opts.limit)}`);
if (limit > EXCHANGE_WRITE_LIMIT)
throw new RangeError(`limit must not exceed the exchange limit ${EXCHANGE_WRITE_LIMIT}, got ${String(opts.limit)}`);
if (!(windowMs >= 1)) throw new RangeError(`windowMs must be >= 1, got ${String(opts.windowMs)}`);
if (!(reserve >= 0) || reserve > limit)
throw new RangeError(`reserve must be in [0, limit], got ${String(opts.reserve)}`);
const clock = opts.now ?? Date.now;
let stamps: number[] = [];
const prune = (now: number) => {
const cutoff = now - windowMs;
let drop = 0;
while (drop < stamps.length && (stamps[drop] as number) <= cutoff) drop++;
if (drop > 0) stamps = stamps.slice(drop);
};
const budget = (now: number): WriteBudget => {
prune(now);
return {
used: stamps.length,
limit,
reserve,
freeOrdinary: Math.max(0, limit - reserve - stamps.length),
freeCritical: Math.max(0, limit - stamps.length),
};
};
return {
tryTake(critical, now = clock()) {
prune(now);
const allowance = critical ? limit : Math.max(0, limit - reserve);
if (stamps.length >= allowance) {
const note = critical ? '' : ` (${reserve} reserved for cancels and reduceOnly orders)`;
return {
ok: false,
rateLimited: true,
used: stamps.length,
error: `write budget ${stamps.length}/${limit} per ${Math.round(windowMs / 1000)}s exhausted${note}`,
};
}
// Insert keeping order (a caller may pass an older `now` in tests).
const last = stamps[stamps.length - 1];
if (last === undefined || last <= now) stamps.push(now);
else {
stamps.push(now);
stamps.sort((a, b) => a - b);
}
return { ok: true, used: stamps.length };
},
budget(now = clock()) {
return budget(now);
},
seedAsExhausted(now = clock()) {
stamps = seedWriteWindow(limit, now, windowMs);
},
seedFromRecentWrites(timestamps, now = clock()) {
const cutoff = now - windowMs;
const merged = [...stamps];
for (const t of timestamps) if (Number.isFinite(t) && t > cutoff && t <= now) merged.push(t);
merged.sort((a, b) => a - b);
stamps = merged;
},
stamps(now = clock()) {
prune(now);
return [...stamps];
},
reset() {
stamps = [];
},
};
}