📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
// Zero-dependency concurrency + rate-limit primitives for `vercel metrics`. API cap is 100 req / 60s / team.
|
||||
// CLI fails fast on 429 and doesn't surface Retry-After, so we back off blind.
|
||||
|
||||
const DEFAULT_CONCURRENCY = 8;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
// Wait most of a 60s window when rate-limited — we don't know how much headroom remains. Jitter prevents lockstep retry.
|
||||
const BASE_BACKOFF_MS = 60_000;
|
||||
const JITTER_MS = 15_000;
|
||||
// 20% headroom under the 100/60s cap for the user's other concurrent CLI usage.
|
||||
const DEFAULT_RATE_LIMIT = 80;
|
||||
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
||||
const DAILY_OBSERVABILITY_LIMIT_RE = /daily.*observability.*query limit/i;
|
||||
|
||||
let dailyQuotaBlock = null;
|
||||
|
||||
export function resolveConcurrency() {
|
||||
return parsePositiveIntEnv('VERCEL_OPTIMIZE_METRIC_CONCURRENCY', DEFAULT_CONCURRENCY);
|
||||
}
|
||||
|
||||
// Format: VERCEL_OPTIMIZE_METRIC_RATE=N or N/60s.
|
||||
export function resolveRateLimit() {
|
||||
const env = process.env.VERCEL_OPTIMIZE_METRIC_RATE;
|
||||
if (env == null || env === '') return { maxCalls: DEFAULT_RATE_LIMIT, windowMs: DEFAULT_RATE_WINDOW_MS };
|
||||
const m = String(env).trim().match(/^(\d+)(?:\/(\d+)([sm])?)?$/);
|
||||
if (!m) return { maxCalls: DEFAULT_RATE_LIMIT, windowMs: DEFAULT_RATE_WINDOW_MS };
|
||||
const maxCalls = Number(m[1]);
|
||||
if (!Number.isInteger(maxCalls) || maxCalls < 1) {
|
||||
return { maxCalls: DEFAULT_RATE_LIMIT, windowMs: DEFAULT_RATE_WINDOW_MS };
|
||||
}
|
||||
if (!m[2]) return { maxCalls, windowMs: DEFAULT_RATE_WINDOW_MS };
|
||||
const unit = m[3] === 'm' ? 60_000 : 1_000;
|
||||
const windowMs = Number(m[2]) * unit;
|
||||
return { maxCalls, windowMs };
|
||||
}
|
||||
|
||||
function parsePositiveIntEnv(name, defaultValue) {
|
||||
const env = process.env[name];
|
||||
if (env == null || env === '') return defaultValue;
|
||||
const n = Number(env);
|
||||
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) return defaultValue;
|
||||
return n;
|
||||
}
|
||||
|
||||
// FIFO semaphore. Caller MUST call returned release() exactly once.
|
||||
export class SemaphoreAbortError extends Error {
|
||||
constructor(result) {
|
||||
super('Semaphore acquire aborted');
|
||||
this.name = 'SemaphoreAbortError';
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export class Semaphore {
|
||||
constructor(max) {
|
||||
if (!Number.isInteger(max) || max < 1) {
|
||||
throw new Error(`Semaphore: max must be a positive integer (got ${max})`);
|
||||
}
|
||||
this.max = max;
|
||||
this.inFlight = 0;
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
async acquire(opts = {}) {
|
||||
const abortIf = opts.abortIf;
|
||||
const preAbort = abortIf?.();
|
||||
if (preAbort) throw new SemaphoreAbortError(preAbort);
|
||||
if (this.inFlight < this.max) {
|
||||
this.inFlight++;
|
||||
return () => this.release();
|
||||
}
|
||||
await new Promise((resolve) => this.waiters.push(resolve));
|
||||
const postAbort = abortIf?.();
|
||||
if (postAbort) {
|
||||
this.wakeNext();
|
||||
throw new SemaphoreAbortError(postAbort);
|
||||
}
|
||||
this.inFlight++;
|
||||
return () => this.release();
|
||||
}
|
||||
|
||||
release() {
|
||||
this.inFlight--;
|
||||
this.wakeNext();
|
||||
}
|
||||
|
||||
wakeNext() {
|
||||
const next = this.waiters.shift();
|
||||
if (next) next();
|
||||
}
|
||||
|
||||
async run(fn, opts = {}) {
|
||||
const release = await this.acquire(opts);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load-bearing — semaphore alone is insufficient (8 concurrent × ~1s queries = 480/min, well above the 100/min cap).
|
||||
export class SlidingWindowRateLimiter {
|
||||
constructor(maxCalls, windowMs, opts = {}) {
|
||||
if (!Number.isInteger(maxCalls) || maxCalls < 1) {
|
||||
throw new Error(`SlidingWindowRateLimiter: maxCalls must be >=1 (got ${maxCalls})`);
|
||||
}
|
||||
if (!Number.isFinite(windowMs) || windowMs < 1) {
|
||||
throw new Error(`SlidingWindowRateLimiter: windowMs must be >0 (got ${windowMs})`);
|
||||
}
|
||||
this.maxCalls = maxCalls;
|
||||
this.windowMs = windowMs;
|
||||
this.timestamps = []; // ascending order
|
||||
this.now = opts.now ?? (() => Date.now());
|
||||
this.sleep = opts.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
async acquire() {
|
||||
while (true) {
|
||||
this.prune();
|
||||
if (this.timestamps.length < this.maxCalls) {
|
||||
this.timestamps.push(this.now());
|
||||
return;
|
||||
}
|
||||
// Small buffer avoids racing the window boundary.
|
||||
const oldestExpiresAt = this.timestamps[0] + this.windowMs;
|
||||
const sleepMs = Math.max(50, oldestExpiresAt - this.now() + 100);
|
||||
await this.sleep(sleepMs);
|
||||
}
|
||||
}
|
||||
|
||||
prune() {
|
||||
const cutoff = this.now() - this.windowMs;
|
||||
while (this.timestamps.length > 0 && this.timestamps[0] < cutoff) {
|
||||
this.timestamps.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composes Semaphore + RateLimiter: bounds both burst (8 concurrent) and sustained throughput (80/60s).
|
||||
let metricThrottleSingleton = null;
|
||||
export function getMetricThrottle() {
|
||||
if (!metricThrottleSingleton) {
|
||||
const semaphore = new Semaphore(resolveConcurrency());
|
||||
const { maxCalls, windowMs } = resolveRateLimit();
|
||||
const rateLimiter = new SlidingWindowRateLimiter(maxCalls, windowMs);
|
||||
metricThrottleSingleton = {
|
||||
semaphore,
|
||||
rateLimiter,
|
||||
maxCalls,
|
||||
windowMs,
|
||||
async run(fn) {
|
||||
const cached = getDailyQuotaBlock();
|
||||
if (cached) return dailyQuotaResult(cached);
|
||||
let release;
|
||||
try {
|
||||
release = await semaphore.acquire({ abortIf: () => {
|
||||
const block = getDailyQuotaBlock();
|
||||
return block ? dailyQuotaResult(block) : null;
|
||||
} });
|
||||
} catch (err) {
|
||||
if (err instanceof SemaphoreAbortError) return err.result;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const afterAcquire = getDailyQuotaBlock();
|
||||
if (afterAcquire) return dailyQuotaResult(afterAcquire);
|
||||
await rateLimiter.acquire();
|
||||
const result = await fn();
|
||||
if (isDailyQuotaExceeded(result)) {
|
||||
const block = setDailyQuotaBlocked(result);
|
||||
return dailyQuotaResult(block, result);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return metricThrottleSingleton;
|
||||
}
|
||||
|
||||
// Back-compat alias — returns the throttle object (compatible `.run(fn)` shape).
|
||||
export const getMetricSemaphore = getMetricThrottle;
|
||||
|
||||
export function _resetMetricSemaphoreForTests() {
|
||||
metricThrottleSingleton = null;
|
||||
dailyQuotaBlock = null;
|
||||
}
|
||||
|
||||
export async function retryOnRateLimit(fn, opts = {}) {
|
||||
const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||
const baseBackoffMs = opts.baseBackoffMs ?? BASE_BACKOFF_MS;
|
||||
const jitterMs = opts.jitterMs ?? JITTER_MS;
|
||||
const sleep = opts.sleep ?? defaultSleep;
|
||||
const onRetry = opts.onRetry;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const result = await fn();
|
||||
if (!isRateLimited(result) || attempt >= maxRetries) return result;
|
||||
attempt++;
|
||||
// attempt 1 = 1x, 2 = 1.5x, 3 = 2x of base.
|
||||
const factor = 1 + (attempt - 1) * 0.5;
|
||||
const jitter = jitterMs > 0 ? Math.random() * jitterMs : 0;
|
||||
const delay = Math.round(baseBackoffMs * factor + jitter);
|
||||
if (onRetry) onRetry(attempt, delay, result);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Variants: code='RATE_LIMITED' (canonical), 'rate_limited', or 'EXIT_1' + stderr match.
|
||||
export function isRateLimited(result) {
|
||||
if (!result || result.ok !== false) return false;
|
||||
const code = String(result.code ?? '').toLowerCase();
|
||||
if (code === 'rate_limited' || code === '429') return true;
|
||||
const stderr = String(result.stderr ?? '').toLowerCase();
|
||||
if (stderr.includes('rate limit') || stderr.includes('rate_limited') || stderr.includes('too many requests')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDailyQuotaExceeded(result) {
|
||||
if (!result || result.ok !== false) return false;
|
||||
const code = String(result.code ?? '');
|
||||
if (code.toUpperCase() === 'DAILY_QUOTA_EXCEEDED') return true;
|
||||
const haystack = [
|
||||
result.message,
|
||||
result.stderr,
|
||||
result.stdout,
|
||||
result.detail,
|
||||
].filter(Boolean).join('\n');
|
||||
return DAILY_OBSERVABILITY_LIMIT_RE.test(haystack);
|
||||
}
|
||||
|
||||
export function setDailyQuotaBlocked(result, nowMs = Date.now()) {
|
||||
dailyQuotaBlock = {
|
||||
untilMs: utcMidnightAfter(nowMs),
|
||||
originalCode: result?.code ?? null,
|
||||
message: result?.message || result?.stderr || 'Daily Observability query limit reached.',
|
||||
};
|
||||
return dailyQuotaBlock;
|
||||
}
|
||||
|
||||
export function getDailyQuotaBlock(nowMs = Date.now()) {
|
||||
if (!dailyQuotaBlock) return null;
|
||||
if (dailyQuotaBlock.untilMs <= nowMs) {
|
||||
dailyQuotaBlock = null;
|
||||
return null;
|
||||
}
|
||||
return dailyQuotaBlock;
|
||||
}
|
||||
|
||||
export function utcMidnightAfter(nowMs) {
|
||||
const d = new Date(nowMs);
|
||||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
function dailyQuotaResult(block, sourceResult = null) {
|
||||
return {
|
||||
...(sourceResult && typeof sourceResult === 'object' ? sourceResult : {}),
|
||||
ok: false,
|
||||
code: 'DAILY_QUOTA_EXCEEDED',
|
||||
message: block.message,
|
||||
cachedUntil: new Date(block.untilMs).toISOString(),
|
||||
originalCode: sourceResult?.originalCode ?? sourceResult?.code ?? block.originalCode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user