UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

104 lines • 4.49 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { isSystemClock, SystemClock } from "../../utils/clock.js"; /** * Spaces calls that share a key to at most `rate` per second. * * For an API that meters requests per second per resource and answers a * burst with a penalty window rather than a queue (Airtable allows 5 per * base, then refuses everything for 30 seconds), spacing the calls up front * is cheaper than any retry. Each call reserves the next free slot for its * key before it awaits anything, so concurrent callers queue in arrival * order with no lock. A caller woken late (a blocked event loop, a throttled * browser timer) moves every slot behind it back by as much, so a stall * cannot release the callers queued behind it in one burst. */ export class RateLimiter { interval; clock; next = new Map(); shift = new Map(); schedule = 0; holders = new Set(); /** * @param clock what slots are read and waited on; omitted means the system * clock until `bindClock` names the workspace's. */ constructor(rate, clock) { if (!(rate > 0)) throw new RangeError(`rate must be positive, got ${String(rate)}`); this.interval = 1 / rate; this.clock = clock ?? new SystemClock(); } /** * Read and wait on `clock` while `owner` holds the limiter. Slots taken on * another clock mean nothing on this one, so they are dropped; binding the * clock already in use keeps them, so a second mount of one accessor cannot * release a burst. Slots can follow one clock only, so while another owner * holds the limiter a different clock is refused; two system clocks agree. * * @param owner who holds it, released by the same key. * @throws Error when another owner holds the limiter on another clock. */ bindClock(clock, owner) { const same = clock === this.clock || (isSystemClock(clock) && isSystemClock(this.clock)); if (!same) { if ([...this.holders].some((held) => held !== owner)) { throw new Error('this client is held by a workspace on another clock; ' + 'unmount or uninstall it there, or give both one clock'); } this.clock = clock; this.schedule += 1; this.next.clear(); this.shift.clear(); } this.holders.add(owner); } /** Let `owner` go; once none holds, another clock may bind. */ releaseClock(owner) { this.holders.delete(owner); } /** * Wait for this key's next slot (an Airtable base id). A caller waits out its * slot on the clock it took it on; once another clock is bound, even one it * then returns to, that slot means nothing on the new schedule, so the caller * leaves it alone. */ async acquire(key) { const clock = this.clock; const schedule = this.schedule; let now = clock.monotonic(); let slot = Math.max(now, this.next.get(key) ?? now); this.next.set(key, slot + this.interval); if (slot <= now) return; let seen = this.shift.get(key) ?? 0; await clock.sleep(slot - now); for (let moved = this.shift.get(key) ?? 0; this.schedule === schedule && moved !== seen; moved = this.shift.get(key) ?? 0) { slot += moved - seen; seen = moved; now = clock.monotonic(); if (slot > now) await clock.sleep(slot - now); } if (this.schedule !== schedule) return; const late = clock.monotonic() - slot; if (late > 0) { this.shift.set(key, seen + late); this.next.set(key, (this.next.get(key) ?? slot) + late); } } } //# sourceMappingURL=rate_limit.js.map