Skip to content
markpaper

src/writeBudget/window.ts

v0.2.1 · 6.2 KB

Download file
// 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 = [];
    },
  };
}
All files