@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
104 lines • 4.49 kB
JavaScript
// ========= 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