// 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)); }