@nextn/outbound-guard 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/errors.ts","../src/limiter.ts","../src/http.ts"],"sourcesContent":["// src/client.ts\r\nimport { EventEmitter } from \"node:events\";\r\nimport { ConcurrencyLimiter } from \"./limiter.js\";\r\nimport { doHttpRequest } from \"./http.js\";\r\nimport {\r\n QueueFullError,\r\n RequestTimeoutError,\r\n ResilientHttpError,\r\n HalfOpenRejectedError,\r\n UpstreamUnhealthyError,\r\n} from \"./errors.js\";\r\nimport type {\r\n MicroCacheOptions,\r\n ResilientHttpClientOptions,\r\n ResilientRequest,\r\n ResilientResponse,\r\n} from \"./types.js\";\r\n\r\n/* ---------------------------- helpers ---------------------------- */\r\n\r\nfunction normalizeUrlForKey(rawUrl: string): string {\r\n const u = new URL(rawUrl);\r\n u.hostname = u.hostname.toLowerCase();\r\n\r\n const isHttpDefault = u.protocol === \"http:\" && u.port === \"80\";\r\n const isHttpsDefault = u.protocol === \"https:\" && u.port === \"443\";\r\n if (isHttpDefault || isHttpsDefault) u.port = \"\";\r\n\r\n return u.toString();\r\n}\r\n\r\nfunction baseUrlKey(rawUrl: string): string {\r\n const u = new URL(rawUrl);\r\n u.hostname = u.hostname.toLowerCase();\r\n\r\n const isHttpDefault = u.protocol === \"http:\" && u.port === \"80\";\r\n const isHttpsDefault = u.protocol === \"https:\" && u.port === \"443\";\r\n if (isHttpDefault || isHttpsDefault) u.port = \"\";\r\n\r\n return `${u.protocol}//${u.host}`;\r\n}\r\n\r\nfunction defaultMicroCacheKeyFn(req: ResilientRequest): string {\r\n return `GET ${normalizeUrlForKey(req.url)}`;\r\n}\r\n\r\nfunction genRequestId(): string {\r\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\r\n}\r\n\r\nfunction sleep(ms: number): Promise<void> {\r\n return new Promise((r) => setTimeout(r, ms));\r\n}\r\n\r\nfunction clamp(n: number, lo: number, hi: number): number {\r\n return Math.max(lo, Math.min(hi, n));\r\n}\r\n\r\nfunction jitterMs(ms: number): number {\r\n const mult = 0.8 + Math.random() * 0.4; // [0.8, 1.2]\r\n return Math.round(ms * mult);\r\n}\r\n\r\n\r\n/* ---------------------------- health ---------------------------- */\r\n\r\ntype HealthState = \"OPEN\" | \"CLOSED\" | \"HALF_OPEN\";\r\ntype Outcome = \"SUCCESS\" | \"HARD_FAIL\" | \"SOFT_FAIL\";\r\n\r\ntype HealthTracker = {\r\n state: HealthState;\r\n\r\n window: Outcome[];\r\n windowSize: number; // 20\r\n minSamples: number; // 10\r\n\r\n consecutiveHardFails: number; // trip at 3\r\n\r\n cooldownBaseMs: number; // 1000\r\n cooldownCapMs: number; // 30000\r\n cooldownMs: number; // current backoff\r\n cooldownUntil: number;\r\n\r\n probeInFlight: boolean; // probeConcurrency=1\r\n probeRemaining: number; // 1 probe per half-open\r\n\r\n stableNonHard: number; // reset backoff after 5 non-hard after open\r\n};\r\n\r\nconst SOFT_FAIL_STATUSES = new Set([429, 502, 503, 504]);\r\n\r\nfunction classifyHttpStatus(status: number): Outcome {\r\n if (status >= 200 && status < 300) return \"SUCCESS\";\r\n if (SOFT_FAIL_STATUSES.has(status)) return \"SOFT_FAIL\";\r\n return \"SUCCESS\"; // do not penalize health for other statuses (incl 4xx except 429)\r\n}\r\n\r\nfunction computeRates(window: Outcome[]) {\r\n const total = window.length;\r\n let hard = 0;\r\n let soft = 0;\r\n for (const o of window) {\r\n if (o === \"HARD_FAIL\") hard += 1;\r\n else if (o === \"SOFT_FAIL\") soft += 1;\r\n }\r\n return {\r\n total,\r\n hard,\r\n soft,\r\n hardFailRate: total === 0 ? 0 : hard / total,\r\n failRate: total === 0 ? 0 : (hard + soft) / total,\r\n };\r\n}\r\n\r\n/**\r\n * Only HTTP-layer failures should count as HARD_FAIL.\r\n * Control-plane rejections must NOT poison health.\r\n */\r\nfunction shouldCountAsHardFail(err: unknown): boolean {\r\n if (err instanceof UpstreamUnhealthyError) return false;\r\n if (err instanceof HalfOpenRejectedError) return false;\r\n if (err instanceof QueueFullError) return false;\r\n\r\n // if your http layer throws this, it's a real hard fail (timeout)\r\n if (err instanceof RequestTimeoutError) return true;\r\n\r\n // If it’s a known library error but not one of the above, be conservative:\r\n // treat it as NOT a health signal unless it’s clearly HTTP-related.\r\n if (err instanceof ResilientHttpError) return false;\r\n\r\n // Unknown thrown error: assume it's an HTTP/network failure.\r\n return true;\r\n}\r\n\r\n/* ---------------------------- microcache types ---------------------------- */\r\n\r\ntype CacheEntry = {\r\n createdAt: number;\r\n expiresAt: number;\r\n value: ResilientResponse;\r\n};\r\n\r\ntype InFlightGroup = {\r\n promise: Promise<ResilientResponse>;\r\n windowStartMs: number;\r\n waiters: number;\r\n};\r\n\r\n/* ---------------------------- client ---------------------------- */\r\n\r\nexport class ResilientHttpClient extends EventEmitter {\r\n private readonly requestTimeoutMs: number;\r\n private readonly healthEnabled: boolean;\r\n\r\n private readonly limiters = new Map<string, ConcurrencyLimiter>();\r\n private readonly health = new Map<string, HealthTracker>();\r\n\r\n private readonly microCache?: Required<\r\n Pick<\r\n MicroCacheOptions,\r\n | \"enabled\"\r\n | \"ttlMs\"\r\n | \"maxStaleMs\"\r\n | \"maxEntries\"\r\n | \"maxWaiters\"\r\n | \"followerTimeoutMs\"\r\n | \"keyFn\"\r\n >\r\n > & {\r\n retry?: {\r\n maxAttempts: number;\r\n baseDelayMs: number;\r\n maxDelayMs: number;\r\n retryOnStatus: number[];\r\n };\r\n };\r\n\r\n private cache?: Map<string, CacheEntry>;\r\n private inFlight?: Map<string, InFlightGroup>;\r\n\r\n private microCacheReqCount = 0;\r\n private readonly cleanupEveryNRequests = 100;\r\n\r\n constructor(private readonly opts: ResilientHttpClientOptions) {\r\n super();\r\n this.requestTimeoutMs = opts.requestTimeoutMs;\r\n this.healthEnabled = opts.health?.enabled ?? true;\r\n\r\n const mc = opts.microCache;\r\n if (mc?.enabled) {\r\n const retry = mc.retry\r\n ? {\r\n maxAttempts: mc.retry.maxAttempts ?? 3,\r\n baseDelayMs: mc.retry.baseDelayMs ?? 50,\r\n maxDelayMs: mc.retry.maxDelayMs ?? 200,\r\n retryOnStatus: mc.retry.retryOnStatus ?? [429, 502, 503, 504],\r\n }\r\n : undefined;\r\n\r\n this.microCache = {\r\n enabled: true,\r\n ttlMs: mc.ttlMs ?? 1000,\r\n maxStaleMs: mc.maxStaleMs ?? 10_000,\r\n maxEntries: mc.maxEntries ?? 500,\r\n maxWaiters: mc.maxWaiters ?? 1000,\r\n followerTimeoutMs: mc.followerTimeoutMs ?? 5000,\r\n keyFn: mc.keyFn ?? defaultMicroCacheKeyFn,\r\n retry,\r\n };\r\n\r\n this.cache = new Map();\r\n this.inFlight = new Map();\r\n }\r\n }\r\n\r\n async request(req: ResilientRequest): Promise<ResilientResponse> {\r\n if (this.microCache?.enabled && req.method === \"GET\" && req.body == null) {\r\n return this.requestWithMicroCache(req);\r\n }\r\n return this.execute(req, { allowProbe: false });\r\n }\r\n\r\n snapshot(): { inFlight: number; queueDepth: number } {\r\n let inFlight = 0;\r\n let queueDepth = 0;\r\n for (const l of this.limiters.values()) {\r\n const s = l.snapshot();\r\n inFlight += s.inFlight;\r\n queueDepth += s.queueDepth;\r\n }\r\n return { inFlight, queueDepth };\r\n }\r\n\r\n /* ---------------- internals ---------------- */\r\n\r\n private getLimiter(baseKey: string): ConcurrencyLimiter {\r\n let l = this.limiters.get(baseKey);\r\n if (!l) {\r\n l = new ConcurrencyLimiter({\r\n maxInFlight: this.opts.maxInFlight,\r\n maxQueue: this.opts.maxInFlight * 10, // hidden factor\r\n });\r\n this.limiters.set(baseKey, l);\r\n }\r\n return l;\r\n }\r\n\r\n private getHealth(baseKey: string): HealthTracker {\r\n let h = this.health.get(baseKey);\r\n if (!h) {\r\n h = {\r\n state: \"OPEN\",\r\n window: [],\r\n windowSize: 20,\r\n minSamples: 10,\r\n consecutiveHardFails: 0,\r\n cooldownBaseMs: 1000,\r\n cooldownCapMs: 30_000,\r\n cooldownMs: 1000,\r\n cooldownUntil: 0,\r\n probeInFlight: false,\r\n probeRemaining: 0,\r\n stableNonHard: 0,\r\n };\r\n this.health.set(baseKey, h);\r\n }\r\n return h;\r\n }\r\n\r\n private closeHealth(baseKey: string, reason: string): void {\r\n const h = this.getHealth(baseKey);\r\n if (h.state === \"CLOSED\") return;\r\n\r\n h.state = \"CLOSED\";\r\n h.cooldownUntil = Date.now() + jitterMs(h.cooldownMs);\r\n h.cooldownMs = Math.min(h.cooldownMs * 2, h.cooldownCapMs);\r\n\r\n // reject queued immediately\r\n this.getLimiter(baseKey).flush(new UpstreamUnhealthyError(baseKey, \"CLOSED\"));\r\n\r\n const rates = computeRates(h.window);\r\n this.emit(\"health:closed\", {\r\n baseUrl: baseKey,\r\n reason,\r\n cooldownMs: h.cooldownUntil - Date.now(),\r\n hardFailRate: rates.hardFailRate,\r\n failRate: rates.failRate,\r\n samples: rates.total,\r\n });\r\n }\r\n\r\n private halfOpenHealth(baseKey: string): void {\r\n const h = this.getHealth(baseKey);\r\n if (h.state !== \"CLOSED\") return;\r\n\r\n h.state = \"HALF_OPEN\";\r\n h.probeInFlight = false;\r\n h.probeRemaining = 1;\r\n this.emit(\"health:half_open\", { baseUrl: baseKey });\r\n }\r\n\r\n private openHealth(baseKey: string): void {\r\n const h = this.getHealth(baseKey);\r\n h.state = \"OPEN\";\r\n h.window = [];\r\n h.consecutiveHardFails = 0;\r\n h.probeInFlight = false;\r\n h.probeRemaining = 0;\r\n h.stableNonHard = 0;\r\n this.emit(\"health:open\", { baseUrl: baseKey });\r\n }\r\n\r\n private recordOutcome(baseKey: string, outcome: Outcome): void {\r\n const h = this.getHealth(baseKey);\r\n\r\n h.window.push(outcome);\r\n while (h.window.length > h.windowSize) h.window.shift();\r\n\r\n if (outcome === \"HARD_FAIL\") h.consecutiveHardFails += 1;\r\n else h.consecutiveHardFails = 0;\r\n\r\n // stabilization (reset backoff after 5 non-hard in OPEN)\r\n if (h.state === \"OPEN\") {\r\n if (outcome !== \"HARD_FAIL\") {\r\n h.stableNonHard += 1;\r\n if (h.stableNonHard >= 5) {\r\n h.cooldownMs = h.cooldownBaseMs;\r\n }\r\n } else {\r\n h.stableNonHard = 0;\r\n }\r\n }\r\n\r\n if (!this.healthEnabled) return;\r\n\r\n if (h.consecutiveHardFails >= 3) {\r\n this.closeHealth(baseKey, \"3 consecutive hard failures\");\r\n return;\r\n }\r\n\r\n const rates = computeRates(h.window);\r\n if (rates.total >= h.minSamples) {\r\n if (rates.hardFailRate >= 0.3) {\r\n this.closeHealth(baseKey, \"hardFailRate >= 30%\");\r\n return;\r\n }\r\n if (rates.failRate >= 0.5) {\r\n this.closeHealth(baseKey, \"failRate >= 50%\");\r\n return;\r\n }\r\n }\r\n }\r\n\r\n private async execute(req: ResilientRequest, opts: { allowProbe: boolean }): Promise<ResilientResponse> {\r\n const baseKey = baseUrlKey(req.url);\r\n const h = this.getHealth(baseKey);\r\n const limiter = this.getLimiter(baseKey);\r\n\r\n if (this.healthEnabled) {\r\n if (h.state === \"CLOSED\") {\r\n if (Date.now() >= h.cooldownUntil) {\r\n this.halfOpenHealth(baseKey);\r\n } else {\r\n throw new UpstreamUnhealthyError(baseKey, \"CLOSED\");\r\n }\r\n }\r\n\r\n if (h.state === \"HALF_OPEN\") {\r\n if (!opts.allowProbe) throw new HalfOpenRejectedError(baseKey);\r\n if (h.probeRemaining <= 0 || h.probeInFlight) throw new HalfOpenRejectedError(baseKey);\r\n h.probeInFlight = true;\r\n h.probeRemaining -= 1;\r\n }\r\n }\r\n\r\n const requestId = genRequestId();\r\n const start = Date.now();\r\n\r\n try {\r\n // Probes should not wait in queue.\r\n if (this.healthEnabled && h.state === \"HALF_OPEN\") {\r\n await limiter.acquireNoQueue();\r\n } else {\r\n await limiter.acquire();\r\n }\r\n } catch (err) {\r\n this.emit(\"request:rejected\", { requestId, request: req, error: err });\r\n throw err;\r\n }\r\n\r\n this.emit(\"request:start\", { requestId, request: req });\r\n\r\n try {\r\n const res = await doHttpRequest(req, this.requestTimeoutMs);\r\n const durationMs = Date.now() - start;\r\n\r\n const outcome = classifyHttpStatus(res.status);\r\n this.recordOutcome(baseKey, outcome);\r\n\r\n // Probe decision\r\n if (this.healthEnabled && h.state === \"HALF_OPEN\") {\r\n this.emit(\"health:probe\", { baseUrl: baseKey, outcome, status: res.status });\r\n if (res.status >= 200 && res.status < 300) {\r\n this.openHealth(baseKey);\r\n } else {\r\n this.closeHealth(baseKey, `probe failed status=${res.status}`);\r\n }\r\n }\r\n\r\n this.emit(\"request:success\", { requestId, request: req, status: res.status, durationMs });\r\n return res;\r\n } catch (err) {\r\n const durationMs = Date.now() - start;\r\n\r\n if (shouldCountAsHardFail(err)) {\r\n this.recordOutcome(baseKey, \"HARD_FAIL\");\r\n }\r\n\r\n if (this.healthEnabled && h.state === \"HALF_OPEN\") {\r\n this.emit(\"health:probe\", { baseUrl: baseKey, outcome: \"HARD_FAIL\", error: err });\r\n this.closeHealth(baseKey, \"probe hard failure\");\r\n }\r\n\r\n this.emit(\"request:failure\", { requestId, request: req, error: err, durationMs });\r\n throw err;\r\n } finally {\r\n // Clear probe flag (if any)\r\n if (this.healthEnabled && h.state === \"HALF_OPEN\") {\r\n h.probeInFlight = false;\r\n }\r\n limiter.release();\r\n }\r\n }\r\n\r\n /* ---------------- microcache ---------------- */\r\n\r\n private cloneResponse(res: ResilientResponse): ResilientResponse {\r\n return { status: res.status, headers: { ...res.headers }, body: new Uint8Array(res.body) };\r\n }\r\n\r\n private maybeCleanupExpired(cache: Map<string, CacheEntry>, maxStaleMs: number): void {\r\n this.microCacheReqCount++;\r\n if (this.microCacheReqCount % this.cleanupEveryNRequests !== 0) return;\r\n\r\n const now = Date.now();\r\n for (const [k, v] of cache.entries()) {\r\n if (now - v.createdAt > maxStaleMs) cache.delete(k);\r\n }\r\n }\r\n\r\n private evictIfNeeded(cache: Map<string, CacheEntry>, maxEntries: number): void {\r\n while (cache.size >= maxEntries) {\r\n const oldestKey = cache.keys().next().value as string | undefined;\r\n if (!oldestKey) break;\r\n cache.delete(oldestKey);\r\n }\r\n }\r\n\r\n private isRetryableStatus(status: number, retryOnStatus: number[]): boolean {\r\n return retryOnStatus.includes(status);\r\n }\r\n\r\n private computeBackoffMs(attemptIndex: number, baseDelayMs: number, maxDelayMs: number): number {\r\n const exp = baseDelayMs * Math.pow(2, attemptIndex - 1);\r\n const capped = clamp(exp, baseDelayMs, maxDelayMs);\r\n const jitter = 0.5 + Math.random();\r\n return Math.round(capped * jitter);\r\n }\r\n\r\n private async fetchWithLeaderRetry(req: ResilientRequest): Promise<ResilientResponse> {\r\n const mc = this.microCache!;\r\n const retry = mc.retry;\r\n if (!retry) return this.execute(req, { allowProbe: false });\r\n\r\n const { maxAttempts, baseDelayMs, maxDelayMs, retryOnStatus } = retry;\r\n\r\n let last: ResilientResponse | undefined;\r\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\r\n const res = await this.execute(req, { allowProbe: false });\r\n last = res;\r\n\r\n if (this.isRetryableStatus(res.status, retryOnStatus) && attempt < maxAttempts) {\r\n const delay = this.computeBackoffMs(attempt, baseDelayMs, maxDelayMs);\r\n this.emit(\"microcache:retry\", { url: req.url, attempt, maxAttempts, reason: `status ${res.status}`, delayMs: delay });\r\n await sleep(delay);\r\n continue;\r\n }\r\n return res;\r\n }\r\n return last!;\r\n }\r\n\r\n private async requestWithMicroCache(req: ResilientRequest): Promise<ResilientResponse> {\r\n const mc = this.microCache!;\r\n const cache = this.cache!;\r\n const inFlight = this.inFlight!;\r\n\r\n this.maybeCleanupExpired(cache, mc.maxStaleMs);\r\n\r\n const key = mc.keyFn(req);\r\n const now = Date.now();\r\n\r\n const hit0 = cache.get(key);\r\n if (hit0 && now - hit0.createdAt > mc.maxStaleMs) cache.delete(key);\r\n\r\n const hit = cache.get(key);\r\n if (hit && now < hit.expiresAt) return this.cloneResponse(hit.value);\r\n\r\n // If CLOSED: serve stale if allowed else fail fast\r\n if (this.healthEnabled) {\r\n const baseKey = baseUrlKey(req.url);\r\n const h = this.getHealth(baseKey);\r\n if (h.state === \"CLOSED\") {\r\n const staleAllowed = !!hit && now - hit.createdAt <= mc.maxStaleMs;\r\n if (staleAllowed) return this.cloneResponse(hit!.value);\r\n throw new UpstreamUnhealthyError(baseKey, \"CLOSED\");\r\n }\r\n }\r\n\r\n const group = inFlight.get(key);\r\n if (group) {\r\n const h = cache.get(key);\r\n const staleAllowed = !!h && now - h.createdAt <= mc.maxStaleMs;\r\n\r\n if (h && staleAllowed) return this.cloneResponse(h.value);\r\n\r\n const age = now - group.windowStartMs;\r\n if (age > mc.followerTimeoutMs) {\r\n const err = new Error(`Follower window closed for key=${key}`);\r\n (err as any).name = \"FollowerWindowClosedError\";\r\n throw err;\r\n }\r\n\r\n if (group.waiters >= mc.maxWaiters) {\r\n const err = new Error(`Too many followers for key=${key}`);\r\n (err as any).name = \"TooManyWaitersError\";\r\n throw err;\r\n }\r\n\r\n group.waiters += 1;\r\n try {\r\n const res = await group.promise;\r\n return this.cloneResponse(res);\r\n } finally {\r\n group.waiters -= 1;\r\n }\r\n }\r\n\r\n const prev = cache.get(key);\r\n const prevStaleAllowed = !!prev && now - prev.createdAt <= mc.maxStaleMs;\r\n\r\n const promise = (async () => {\r\n const baseKey = baseUrlKey(req.url);\r\n const h = this.getHealth(baseKey);\r\n const allowProbe = this.healthEnabled && h.state === \"HALF_OPEN\";\r\n\r\n const res = allowProbe\r\n ? await this.execute(req, { allowProbe: true })\r\n : await this.fetchWithLeaderRetry(req);\r\n\r\n if (res.status >= 200 && res.status < 300) {\r\n this.evictIfNeeded(cache, mc.maxEntries);\r\n const t = Date.now();\r\n cache.set(key, { value: this.cloneResponse(res), createdAt: t, expiresAt: t + mc.ttlMs });\r\n }\r\n return res;\r\n })();\r\n\r\n inFlight.set(key, { promise, windowStartMs: Date.now(), waiters: 0 });\r\n\r\n try {\r\n const res = await promise;\r\n\r\n if (!(res.status >= 200 && res.status < 300) && prev && prevStaleAllowed) {\r\n return this.cloneResponse(prev.value);\r\n }\r\n\r\n return this.cloneResponse(res);\r\n } catch (err) {\r\n if (prev && prevStaleAllowed) {\r\n this.emit(\"microcache:refresh_failed\", { key, url: req.url, error: err });\r\n return this.cloneResponse(prev.value);\r\n }\r\n throw err;\r\n } finally {\r\n inFlight.delete(key);\r\n }\r\n }\r\n}\r\n","export abstract class ResilientHttpError extends Error {\r\n public readonly name: string;\r\n constructor(message: string) {\r\n super(message);\r\n this.name = this.constructor.name;\r\n }\r\n}\r\n\r\nexport class QueueFullError extends ResilientHttpError {\r\n constructor(public readonly maxQueue: number) {\r\n super(`Queue is full (maxQueue=${maxQueue}).`);\r\n }\r\n}\r\n\r\nexport class QueueTimeoutError extends ResilientHttpError {\r\n constructor(public readonly enqueueTimeoutMs: number) {\r\n super(`Queue wait exceeded (enqueueTimeoutMs=${enqueueTimeoutMs}).`);\r\n }\r\n}\r\n\r\nexport class RequestTimeoutError extends ResilientHttpError {\r\n constructor(public readonly requestTimeoutMs: number) {\r\n super(`Request timed out (requestTimeoutMs=${requestTimeoutMs}).`);\r\n }\r\n}\r\n\r\n\r\n\r\nexport class UpstreamError extends ResilientHttpError {\r\n constructor(public readonly status: number) {\r\n super(`Upstream returned error status=${status}.`);\r\n }\r\n}\r\n\r\n\r\n\r\n\r\nexport class UpstreamUnhealthyError extends ResilientHttpError {\r\n constructor(public readonly baseUrl: string, public readonly state: string) {\r\n super(`Upstream is unhealthy (state=${state}, baseUrl=${baseUrl}).`);\r\n }\r\n}\r\n\r\nexport class HalfOpenRejectedError extends ResilientHttpError {\r\n constructor(public readonly baseUrl: string) {\r\n super(`Upstream is HALF_OPEN (probe only) for baseUrl=${baseUrl}.`);\r\n }\r\n}\r\n","// src/limiter.ts\r\nimport { QueueFullError } from \"./errors.js\";\r\n\r\ntype ResolveFn = () => void;\r\ntype RejectFn = (err: unknown) => void;\r\n\r\ninterface Waiter {\r\n resolve: ResolveFn;\r\n reject: RejectFn;\r\n}\r\n\r\n/**\r\n * Process-local concurrency limiter with bounded FIFO queue.\r\n *\r\n * - maxInFlight: concurrent permits\r\n * - maxQueue: bounded burst buffer\r\n * - No enqueue-timeout by design.\r\n *\r\n * Also supports:\r\n * - flush(err): reject all queued waiters immediately\r\n * - acquireNoQueue(): for probes (must start now or fail)\r\n */\r\nexport class ConcurrencyLimiter {\r\n private readonly maxInFlight: number;\r\n private readonly maxQueue: number;\r\n\r\n private inFlight = 0;\r\n private queue: Waiter[] = [];\r\n\r\n constructor(opts: { maxInFlight: number; maxQueue: number }) {\r\n if (!Number.isFinite(opts.maxInFlight) || opts.maxInFlight <= 0) {\r\n throw new Error(`maxInFlight must be > 0 (got ${opts.maxInFlight})`);\r\n }\r\n if (!Number.isFinite(opts.maxQueue) || opts.maxQueue < 0) {\r\n throw new Error(`maxQueue must be >= 0 (got ${opts.maxQueue})`);\r\n }\r\n\r\n this.maxInFlight = opts.maxInFlight;\r\n this.maxQueue = opts.maxQueue;\r\n }\r\n\r\n acquire(): Promise<void> {\r\n if (this.inFlight < this.maxInFlight) {\r\n this.inFlight += 1;\r\n return Promise.resolve();\r\n }\r\n\r\n if (this.maxQueue === 0 || this.queue.length >= this.maxQueue) {\r\n return Promise.reject(new QueueFullError(this.maxQueue));\r\n }\r\n\r\n return new Promise<void>((resolve, reject) => {\r\n this.queue.push({ resolve, reject });\r\n });\r\n }\r\n\r\n /**\r\n * Acquire without queueing: either start now or fail.\r\n * Used for HALF_OPEN probes so recovery never waits behind backlog.\r\n */\r\n acquireNoQueue(): Promise<void> {\r\n if (this.inFlight < this.maxInFlight) {\r\n this.inFlight += 1;\r\n return Promise.resolve();\r\n }\r\n // treat as queue full (we don't want a new error type)\r\n return Promise.reject(new QueueFullError(0));\r\n }\r\n\r\n release(): void {\r\n if (this.inFlight <= 0) {\r\n throw new Error(\"release() called when inFlight is already 0\");\r\n }\r\n\r\n const next = this.queue.shift();\r\n if (next) {\r\n next.resolve(); // transfer permit\r\n return;\r\n }\r\n\r\n this.inFlight -= 1;\r\n }\r\n\r\n flush(err: unknown): void {\r\n const q = this.queue;\r\n this.queue = [];\r\n for (const w of q) w.reject(err);\r\n }\r\n\r\n snapshot(): { inFlight: number; queueDepth: number; maxInFlight: number; maxQueue: number } {\r\n return {\r\n inFlight: this.inFlight,\r\n queueDepth: this.queue.length,\r\n maxInFlight: this.maxInFlight,\r\n maxQueue: this.maxQueue,\r\n };\r\n }\r\n}\r\n","// src/http.ts\r\nimport { request as undiciRequest } from \"undici\";\r\nimport { RequestTimeoutError } from \"./errors.js\";\r\nimport type { ResilientRequest, ResilientResponse } from \"./types.js\";\r\n\r\nfunction normalizeHeaders(headers: any): Record<string, string> {\r\n const out: Record<string, string> = {};\r\n if (!headers) return out;\r\n\r\n // undici headers are an object-like structure (headers: Record<string, string | string[]>)\r\n for (const [k, v] of Object.entries(headers)) {\r\n if (Array.isArray(v)) out[k.toLowerCase()] = v.join(\", \");\r\n else if (typeof v === \"string\") out[k.toLowerCase()] = v;\r\n else out[k.toLowerCase()] = String(v);\r\n }\r\n return out;\r\n}\r\n\r\n/**\r\n * Execute a single HTTP request with a hard timeout using AbortController.\r\n * No retries. No breaker. Just raw outbound I/O with a timeout.\r\n */\r\nexport async function doHttpRequest(\r\n req: ResilientRequest,\r\n requestTimeoutMs: number\r\n): Promise<ResilientResponse> {\r\n const ac = new AbortController();\r\n const timer = setTimeout(() => ac.abort(), requestTimeoutMs);\r\n\r\n try {\r\n const res = await undiciRequest(req.url, {\r\n method: req.method,\r\n headers: req.headers,\r\n body: req.body as any,\r\n signal: ac.signal,\r\n });\r\n\r\n const body = await res.body.arrayBuffer();\r\n return {\r\n status: res.statusCode,\r\n headers: normalizeHeaders(res.headers),\r\n body: new Uint8Array(body),\r\n };\r\n } catch (err: any) {\r\n // undici throws AbortError on abort\r\n if (err?.name === \"AbortError\") {\r\n throw new RequestTimeoutError(requestTimeoutMs);\r\n }\r\n throw err;\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextn/outbound-guard",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -1,454 +0,0 @@
1
- "use strict";
2
-
3
- // src/client.ts
4
- var import_node_events = require("events");
5
-
6
- // src/errors.ts
7
- var ResilientHttpError = class extends Error {
8
- name;
9
- constructor(message) {
10
- super(message);
11
- this.name = this.constructor.name;
12
- }
13
- };
14
- var QueueFullError = class extends ResilientHttpError {
15
- constructor(maxQueue) {
16
- super(`Queue is full (maxQueue=${maxQueue}).`);
17
- this.maxQueue = maxQueue;
18
- }
19
- };
20
- var QueueTimeoutError = class extends ResilientHttpError {
21
- constructor(enqueueTimeoutMs) {
22
- super(`Queue wait exceeded (enqueueTimeoutMs=${enqueueTimeoutMs}).`);
23
- this.enqueueTimeoutMs = enqueueTimeoutMs;
24
- }
25
- };
26
- var RequestTimeoutError = class extends ResilientHttpError {
27
- constructor(requestTimeoutMs) {
28
- super(`Request timed out (requestTimeoutMs=${requestTimeoutMs}).`);
29
- this.requestTimeoutMs = requestTimeoutMs;
30
- }
31
- };
32
-
33
- // src/limiter.ts
34
- var ConcurrencyLimiter = class {
35
- maxInFlight;
36
- maxQueue;
37
- enqueueTimeoutMs;
38
- inFlight = 0;
39
- queue = [];
40
- constructor(opts) {
41
- if (!Number.isFinite(opts.maxInFlight) || opts.maxInFlight <= 0) {
42
- throw new Error(`maxInFlight must be > 0 (got ${opts.maxInFlight})`);
43
- }
44
- if (!Number.isFinite(opts.maxQueue) || opts.maxQueue < 0) {
45
- throw new Error(`maxQueue must be >= 0 (got ${opts.maxQueue})`);
46
- }
47
- if (!Number.isFinite(opts.enqueueTimeoutMs) || opts.enqueueTimeoutMs <= 0) {
48
- throw new Error(`enqueueTimeoutMs must be > 0 (got ${opts.enqueueTimeoutMs})`);
49
- }
50
- this.maxInFlight = opts.maxInFlight;
51
- this.maxQueue = opts.maxQueue;
52
- this.enqueueTimeoutMs = opts.enqueueTimeoutMs;
53
- }
54
- /**
55
- * Acquire a permit. Resolves once you are allowed to proceed.
56
- * MUST be followed by `release()` exactly once.
57
- */
58
- acquire() {
59
- if (this.inFlight < this.maxInFlight) {
60
- this.inFlight += 1;
61
- return Promise.resolve();
62
- }
63
- if (this.maxQueue === 0 || this.queue.length >= this.maxQueue) {
64
- return Promise.reject(new QueueFullError(this.maxQueue));
65
- }
66
- return new Promise((resolve, reject) => {
67
- const timer = setTimeout(() => {
68
- const idx = this.queue.findIndex((w) => w.resolve === resolve);
69
- if (idx >= 0) {
70
- const [w] = this.queue.splice(idx, 1);
71
- clearTimeout(w.timer);
72
- }
73
- reject(new QueueTimeoutError(this.enqueueTimeoutMs));
74
- }, this.enqueueTimeoutMs);
75
- this.queue.push({ resolve, reject, timer });
76
- });
77
- }
78
- /**
79
- * Release a permit. Always call this in a `finally` block.
80
- */
81
- release() {
82
- if (this.inFlight <= 0) {
83
- throw new Error("release() called when inFlight is already 0");
84
- }
85
- const next = this.queue.shift();
86
- if (next) {
87
- clearTimeout(next.timer);
88
- next.resolve();
89
- return;
90
- }
91
- this.inFlight -= 1;
92
- }
93
- snapshot() {
94
- return {
95
- inFlight: this.inFlight,
96
- queueDepth: this.queue.length,
97
- maxInFlight: this.maxInFlight,
98
- maxQueue: this.maxQueue
99
- };
100
- }
101
- };
102
-
103
- // src/http.ts
104
- var import_undici = require("undici");
105
- function normalizeHeaders(headers) {
106
- const out = {};
107
- if (!headers) return out;
108
- for (const [k, v] of Object.entries(headers)) {
109
- if (Array.isArray(v)) out[k.toLowerCase()] = v.join(", ");
110
- else if (typeof v === "string") out[k.toLowerCase()] = v;
111
- else out[k.toLowerCase()] = String(v);
112
- }
113
- return out;
114
- }
115
- async function doHttpRequest(req, requestTimeoutMs) {
116
- const ac = new AbortController();
117
- const timer = setTimeout(() => ac.abort(), requestTimeoutMs);
118
- try {
119
- const res = await (0, import_undici.request)(req.url, {
120
- method: req.method,
121
- headers: req.headers,
122
- body: req.body,
123
- signal: ac.signal
124
- });
125
- const body = await res.body.arrayBuffer();
126
- return {
127
- status: res.statusCode,
128
- headers: normalizeHeaders(res.headers),
129
- body: new Uint8Array(body)
130
- };
131
- } catch (err) {
132
- if (err?.name === "AbortError") {
133
- throw new RequestTimeoutError(requestTimeoutMs);
134
- }
135
- throw err;
136
- } finally {
137
- clearTimeout(timer);
138
- }
139
- }
140
-
141
- // src/client.ts
142
- function normalizeUrlForKey(rawUrl) {
143
- const u = new URL(rawUrl);
144
- u.hostname = u.hostname.toLowerCase();
145
- const isHttpDefault = u.protocol === "http:" && u.port === "80";
146
- const isHttpsDefault = u.protocol === "https:" && u.port === "443";
147
- if (isHttpDefault || isHttpsDefault) u.port = "";
148
- return u.toString();
149
- }
150
- function defaultMicroCacheKeyFn(req) {
151
- return `GET ${normalizeUrlForKey(req.url)}`;
152
- }
153
- function genRequestId() {
154
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
155
- }
156
- function sleep(ms) {
157
- return new Promise((r) => setTimeout(r, ms));
158
- }
159
- function clamp(n, lo, hi) {
160
- return Math.max(lo, Math.min(hi, n));
161
- }
162
- var ResilientHttpClient = class extends import_node_events.EventEmitter {
163
- constructor(opts) {
164
- super();
165
- this.opts = opts;
166
- this.limiter = new ConcurrencyLimiter({
167
- maxInFlight: opts.maxInFlight,
168
- maxQueue: opts.maxQueue,
169
- enqueueTimeoutMs: opts.enqueueTimeoutMs
170
- });
171
- this.requestTimeoutMs = opts.requestTimeoutMs;
172
- const mc = opts.microCache;
173
- if (mc?.enabled) {
174
- const retry = mc.retry ? {
175
- maxAttempts: mc.retry.maxAttempts ?? 3,
176
- baseDelayMs: mc.retry.baseDelayMs ?? 50,
177
- maxDelayMs: mc.retry.maxDelayMs ?? 200,
178
- retryOnStatus: mc.retry.retryOnStatus ?? [429, 502, 503, 504]
179
- } : void 0;
180
- this.microCache = {
181
- enabled: true,
182
- ttlMs: mc.ttlMs ?? 1e3,
183
- maxStaleMs: mc.maxStaleMs ?? 1e4,
184
- maxEntries: mc.maxEntries ?? 500,
185
- maxWaiters: mc.maxWaiters ?? 1e3,
186
- followerTimeoutMs: mc.followerTimeoutMs ?? 5e3,
187
- keyFn: mc.keyFn ?? defaultMicroCacheKeyFn,
188
- retry
189
- };
190
- this.cache = /* @__PURE__ */ new Map();
191
- this.inFlight = /* @__PURE__ */ new Map();
192
- }
193
- }
194
- limiter;
195
- requestTimeoutMs;
196
- microCache;
197
- cache;
198
- inFlight;
199
- microCacheReqCount = 0;
200
- cleanupEveryNRequests = 100;
201
- async request(req) {
202
- if (this.microCache?.enabled && req.method === "GET" && req.body == null) {
203
- return this.requestWithMicroCache(req);
204
- }
205
- return this.existingPipeline(req);
206
- }
207
- cloneResponse(res) {
208
- return {
209
- status: res.status,
210
- headers: { ...res.headers },
211
- body: new Uint8Array(res.body)
212
- };
213
- }
214
- maybeCleanupExpired(cache, maxStaleMs) {
215
- this.microCacheReqCount++;
216
- if (this.microCacheReqCount % this.cleanupEveryNRequests !== 0) return;
217
- const now = Date.now();
218
- for (const [k, v] of cache.entries()) {
219
- if (now - v.createdAt > maxStaleMs) cache.delete(k);
220
- }
221
- }
222
- evictIfNeeded(cache, maxEntries) {
223
- while (cache.size >= maxEntries) {
224
- const oldestKey = cache.keys().next().value;
225
- if (!oldestKey) break;
226
- cache.delete(oldestKey);
227
- }
228
- }
229
- isRetryableStatus(status, retryOnStatus) {
230
- return retryOnStatus.includes(status);
231
- }
232
- computeBackoffMs(attemptIndex, baseDelayMs, maxDelayMs) {
233
- const exp = baseDelayMs * Math.pow(2, attemptIndex - 1);
234
- const capped = clamp(exp, baseDelayMs, maxDelayMs);
235
- const jitter = 0.5 + Math.random();
236
- return Math.round(capped * jitter);
237
- }
238
- async fetchWithLeaderRetry(req) {
239
- const mc = this.microCache;
240
- const retry = mc.retry;
241
- if (!retry) return this.existingPipeline(req);
242
- const { maxAttempts, baseDelayMs, maxDelayMs, retryOnStatus } = retry;
243
- let last;
244
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
245
- const res = await this.existingPipeline(req);
246
- last = res;
247
- if (this.isRetryableStatus(res.status, retryOnStatus) && attempt < maxAttempts) {
248
- const delay = this.computeBackoffMs(attempt, baseDelayMs, maxDelayMs);
249
- this.emit("microcache:retry", {
250
- url: req.url,
251
- attempt,
252
- maxAttempts,
253
- reason: `status ${res.status}`,
254
- delayMs: delay
255
- });
256
- await sleep(delay);
257
- continue;
258
- }
259
- return res;
260
- }
261
- return last;
262
- }
263
- /**
264
- * Window behavior:
265
- * - 0..ttlMs: return cache (fresh)
266
- * - ttlMs..maxStaleMs: leader refreshes; others get old value until replaced (stale-while-revalidate)
267
- * - >maxStaleMs: do not serve old; behave like no cache
268
- *
269
- * Follower controls (only when no cache is served):
270
- * - maxWaiters: cap concurrent followers joining the leader
271
- * - followerTimeoutMs: shared "join window" from first follower; after it expires, late followers fail fast until leader completes
272
- */
273
- async requestWithMicroCache(req) {
274
- const mc = this.microCache;
275
- const cache = this.cache;
276
- const inFlight = this.inFlight;
277
- this.maybeCleanupExpired(cache, mc.maxStaleMs);
278
- const key = mc.keyFn(req);
279
- const now = Date.now();
280
- const hit0 = cache.get(key);
281
- if (hit0 && now - hit0.createdAt > mc.maxStaleMs) {
282
- cache.delete(key);
283
- }
284
- const hit = cache.get(key);
285
- if (hit && now < hit.expiresAt) {
286
- return this.cloneResponse(hit.value);
287
- }
288
- const group = inFlight.get(key);
289
- if (group) {
290
- const h = cache.get(key);
291
- const staleAllowed = !!h && now - h.createdAt <= mc.maxStaleMs;
292
- if (h && staleAllowed) {
293
- return this.cloneResponse(h.value);
294
- }
295
- const age = now - group.windowStartMs;
296
- if (age > mc.followerTimeoutMs) {
297
- const err = new Error(`Follower window closed for key=${key}`);
298
- err.name = "FollowerWindowClosedError";
299
- throw err;
300
- }
301
- if (group.waiters >= mc.maxWaiters) {
302
- const err = new Error(`Too many followers for key=${key}`);
303
- err.name = "TooManyWaitersError";
304
- throw err;
305
- }
306
- group.waiters += 1;
307
- try {
308
- const res = await group.promise;
309
- return this.cloneResponse(res);
310
- } finally {
311
- group.waiters -= 1;
312
- }
313
- }
314
- const prev = cache.get(key);
315
- const prevStaleAllowed = !!prev && now - prev.createdAt <= mc.maxStaleMs;
316
- const promise = (async () => {
317
- const res = await this.fetchWithLeaderRetry(req);
318
- if (res.status >= 200 && res.status < 300) {
319
- this.evictIfNeeded(cache, mc.maxEntries);
320
- const t = Date.now();
321
- cache.set(key, {
322
- value: this.cloneResponse(res),
323
- createdAt: t,
324
- expiresAt: t + mc.ttlMs
325
- });
326
- }
327
- return res;
328
- })();
329
- const newGroup = {
330
- promise,
331
- windowStartMs: Date.now(),
332
- waiters: 0
333
- };
334
- inFlight.set(key, newGroup);
335
- try {
336
- const res = await promise;
337
- if (!(res.status >= 200 && res.status < 300) && prev && prevStaleAllowed) {
338
- return this.cloneResponse(prev.value);
339
- }
340
- return this.cloneResponse(res);
341
- } catch (err) {
342
- if (prev && prevStaleAllowed) {
343
- this.emit("microcache:refresh_failed", { key, url: req.url, error: err });
344
- return this.cloneResponse(prev.value);
345
- }
346
- throw err;
347
- } finally {
348
- inFlight.delete(key);
349
- }
350
- }
351
- async existingPipeline(req) {
352
- const requestId = genRequestId();
353
- try {
354
- await this.limiter.acquire();
355
- } catch (err) {
356
- this.emit("request:rejected", { requestId, request: req, error: err });
357
- throw err;
358
- }
359
- const start = Date.now();
360
- this.emit("request:start", { requestId, request: req });
361
- try {
362
- const res = await doHttpRequest(req, this.requestTimeoutMs);
363
- const durationMs = Date.now() - start;
364
- this.emit("request:success", { requestId, request: req, status: res.status, durationMs });
365
- return res;
366
- } catch (err) {
367
- const durationMs = Date.now() - start;
368
- this.emit("request:failure", { requestId, request: req, error: err, durationMs });
369
- throw err;
370
- } finally {
371
- this.limiter.release();
372
- }
373
- }
374
- snapshot() {
375
- const s = this.limiter.snapshot();
376
- return { inFlight: s.inFlight, queueDepth: s.queueDepth };
377
- }
378
- };
379
-
380
- // demo/loadgen.ts
381
- function sleep2(ms) {
382
- return new Promise((r) => setTimeout(r, ms));
383
- }
384
- var client = new ResilientHttpClient({
385
- maxInFlight: 5,
386
- maxQueue: 20,
387
- enqueueTimeoutMs: 100,
388
- requestTimeoutMs: 4e3,
389
- microCache: {
390
- enabled: true,
391
- ttlMs: 1e3,
392
- maxStaleMs: 800,
393
- maxEntries: 100,
394
- // ⭐ important demo knobs
395
- maxWaiters: 10,
396
- followerTimeoutMs: 5e3,
397
- retry: {
398
- maxAttempts: 3,
399
- baseDelayMs: 50,
400
- maxDelayMs: 200,
401
- retryOnStatus: [503]
402
- }
403
- }
404
- });
405
- client.on(
406
- "microcache:retry",
407
- (e) => console.log("[retry]", e.reason, `attempt ${e.attempt}`)
408
- );
409
- client.on(
410
- "microcache:waiters_full",
411
- () => console.log("[shed] follower queue full")
412
- );
413
- client.on(
414
- "microcache:follower_window_closed",
415
- () => console.log("[shed] follower window closed")
416
- );
417
- client.on(
418
- "breaker:state",
419
- (e) => console.log(`[breaker] ${e.key}: ${e.from} -> ${e.to}`)
420
- );
421
- async function burst(label, n) {
422
- console.log(`
423
- === burst: ${label} (${n} requests) ===`);
424
- await Promise.allSettled(
425
- Array.from({ length: n }, async (_, i) => {
426
- try {
427
- const res = await client.request({
428
- method: "GET",
429
- url: "http://localhost:3001/data"
430
- });
431
- console.log(
432
- `[${label} #${i}]`,
433
- res.status,
434
- Buffer.from(res.body).toString()
435
- );
436
- } catch (err) {
437
- console.log(`[${label} #${i}] ERROR`, err.name);
438
- }
439
- })
440
- );
441
- }
442
- (async () => {
443
- await burst("cold-start", 10);
444
- await sleep2(500);
445
- await burst("cached", 10);
446
- await sleep2(1200);
447
- await burst("refresh-with-stale", 10);
448
- await sleep2(900);
449
- await burst("failure", 20);
450
- await sleep2(4e3);
451
- await burst("recovered", 10);
452
- console.log("\nDone.");
453
- })();
454
- //# sourceMappingURL=loadgen.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/client.ts","../../src/errors.ts","../../src/limiter.ts","../../src/http.ts","../../demo/loadgen.ts"],"sourcesContent":["// src/client.ts\r\nimport { EventEmitter } from \"node:events\";\r\nimport { ConcurrencyLimiter } from \"./limiter.js\";\r\nimport { doHttpRequest } from \"./http.js\";\r\nimport type {\r\n MicroCacheOptions,\r\n ResilientHttpClientOptions,\r\n ResilientRequest,\r\n ResilientResponse,\r\n} from \"./types.js\";\r\n\r\nfunction normalizeUrlForKey(rawUrl: string): string {\r\n const u = new URL(rawUrl);\r\n u.hostname = u.hostname.toLowerCase();\r\n\r\n const isHttpDefault = u.protocol === \"http:\" && u.port === \"80\";\r\n const isHttpsDefault = u.protocol === \"https:\" && u.port === \"443\";\r\n if (isHttpDefault || isHttpsDefault) u.port = \"\";\r\n\r\n return u.toString();\r\n}\r\n\r\nfunction defaultMicroCacheKeyFn(req: ResilientRequest): string {\r\n return `GET ${normalizeUrlForKey(req.url)}`;\r\n}\r\n\r\nfunction genRequestId(): string {\r\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\r\n}\r\n\r\nfunction sleep(ms: number): Promise<void> {\r\n return new Promise((r) => setTimeout(r, ms));\r\n}\r\n\r\nfunction clamp(n: number, lo: number, hi: number): number {\r\n return Math.max(lo, Math.min(hi, n));\r\n}\r\n\r\ntype CacheEntry = {\r\n createdAt: number;\r\n expiresAt: number;\r\n value: ResilientResponse;\r\n};\r\n\r\ntype InFlightGroup = {\r\n promise: Promise<ResilientResponse>;\r\n // follower join window\r\n windowStartMs: number;\r\n waiters: number; // followers currently joined (not counting leader)\r\n};\r\n\r\nexport class ResilientHttpClient extends EventEmitter {\r\n private readonly limiter: ConcurrencyLimiter;\r\n private readonly requestTimeoutMs: number;\r\n\r\n private readonly microCache?: Required<\r\n Pick<\r\n MicroCacheOptions,\r\n | \"enabled\"\r\n | \"ttlMs\"\r\n | \"maxStaleMs\"\r\n | \"maxEntries\"\r\n | \"maxWaiters\"\r\n | \"followerTimeoutMs\"\r\n | \"keyFn\"\r\n >\r\n > & {\r\n retry?: {\r\n maxAttempts: number;\r\n baseDelayMs: number;\r\n maxDelayMs: number;\r\n retryOnStatus: number[];\r\n };\r\n };\r\n\r\n private cache?: Map<string, CacheEntry>;\r\n private inFlight?: Map<string, InFlightGroup>;\r\n\r\n private microCacheReqCount = 0;\r\n private readonly cleanupEveryNRequests = 100;\r\n\r\n constructor(private readonly opts: ResilientHttpClientOptions) {\r\n super();\r\n\r\n this.limiter = new ConcurrencyLimiter({\r\n maxInFlight: opts.maxInFlight,\r\n maxQueue: opts.maxQueue,\r\n enqueueTimeoutMs: opts.enqueueTimeoutMs,\r\n });\r\n\r\n this.requestTimeoutMs = opts.requestTimeoutMs;\r\n\r\n const mc = opts.microCache;\r\n if (mc?.enabled) {\r\n const retry = mc.retry\r\n ? {\r\n maxAttempts: mc.retry.maxAttempts ?? 3,\r\n baseDelayMs: mc.retry.baseDelayMs ?? 50,\r\n maxDelayMs: mc.retry.maxDelayMs ?? 200,\r\n retryOnStatus: mc.retry.retryOnStatus ?? [429, 502, 503, 504],\r\n }\r\n : undefined;\r\n\r\n this.microCache = {\r\n enabled: true,\r\n ttlMs: mc.ttlMs ?? 1000,\r\n maxStaleMs: mc.maxStaleMs ?? 10_000,\r\n maxEntries: mc.maxEntries ?? 500,\r\n maxWaiters: mc.maxWaiters ?? 1000,\r\n followerTimeoutMs: mc.followerTimeoutMs ?? 5000,\r\n keyFn: mc.keyFn ?? defaultMicroCacheKeyFn,\r\n retry,\r\n };\r\n\r\n this.cache = new Map();\r\n this.inFlight = new Map();\r\n }\r\n }\r\n\r\n async request(req: ResilientRequest): Promise<ResilientResponse> {\r\n if (this.microCache?.enabled && req.method === \"GET\" && req.body == null) {\r\n return this.requestWithMicroCache(req);\r\n }\r\n return this.existingPipeline(req);\r\n }\r\n\r\n private cloneResponse(res: ResilientResponse): ResilientResponse {\r\n return {\r\n status: res.status,\r\n headers: { ...res.headers },\r\n body: new Uint8Array(res.body),\r\n };\r\n }\r\n\r\n private maybeCleanupExpired(cache: Map<string, CacheEntry>, maxStaleMs: number): void {\r\n this.microCacheReqCount++;\r\n if (this.microCacheReqCount % this.cleanupEveryNRequests !== 0) return;\r\n\r\n const now = Date.now();\r\n for (const [k, v] of cache.entries()) {\r\n if (now - v.createdAt > maxStaleMs) cache.delete(k);\r\n }\r\n }\r\n\r\n private evictIfNeeded(cache: Map<string, CacheEntry>, maxEntries: number): void {\r\n while (cache.size >= maxEntries) {\r\n const oldestKey = cache.keys().next().value as string | undefined;\r\n if (!oldestKey) break;\r\n cache.delete(oldestKey);\r\n }\r\n }\r\n\r\n private isRetryableStatus(status: number, retryOnStatus: number[]): boolean {\r\n return retryOnStatus.includes(status);\r\n }\r\n\r\n private computeBackoffMs(attemptIndex: number, baseDelayMs: number, maxDelayMs: number): number {\r\n const exp = baseDelayMs * Math.pow(2, attemptIndex - 1);\r\n const capped = clamp(exp, baseDelayMs, maxDelayMs);\r\n const jitter = 0.5 + Math.random();\r\n return Math.round(capped * jitter);\r\n }\r\n\r\n private async fetchWithLeaderRetry(req: ResilientRequest): Promise<ResilientResponse> {\r\n const mc = this.microCache!;\r\n const retry = mc.retry;\r\n if (!retry) return this.existingPipeline(req);\r\n\r\n const { maxAttempts, baseDelayMs, maxDelayMs, retryOnStatus } = retry;\r\n\r\n let last: ResilientResponse | undefined;\r\n\r\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\r\n const res = await this.existingPipeline(req);\r\n last = res;\r\n\r\n if (this.isRetryableStatus(res.status, retryOnStatus) && attempt < maxAttempts) {\r\n const delay = this.computeBackoffMs(attempt, baseDelayMs, maxDelayMs);\r\n this.emit(\"microcache:retry\", {\r\n url: req.url,\r\n attempt,\r\n maxAttempts,\r\n reason: `status ${res.status}`,\r\n delayMs: delay,\r\n });\r\n await sleep(delay);\r\n continue;\r\n }\r\n\r\n return res;\r\n }\r\n\r\n // should never\r\n return last!;\r\n }\r\n\r\n /**\r\n * Window behavior:\r\n * - 0..ttlMs: return cache (fresh)\r\n * - ttlMs..maxStaleMs: leader refreshes; others get old value until replaced (stale-while-revalidate)\r\n * - >maxStaleMs: do not serve old; behave like no cache\r\n *\r\n * Follower controls (only when no cache is served):\r\n * - maxWaiters: cap concurrent followers joining the leader\r\n * - followerTimeoutMs: shared \"join window\" from first follower; after it expires, late followers fail fast until leader completes\r\n */\r\n private async requestWithMicroCache(req: ResilientRequest): Promise<ResilientResponse> {\r\n const mc = this.microCache!;\r\n const cache = this.cache!;\r\n const inFlight = this.inFlight!;\r\n\r\n this.maybeCleanupExpired(cache, mc.maxStaleMs);\r\n\r\n const key = mc.keyFn(req);\r\n const now = Date.now();\r\n\r\n // If cached entry exists but too old, delete it\r\n const hit0 = cache.get(key);\r\n if (hit0 && now - hit0.createdAt > mc.maxStaleMs) {\r\n cache.delete(key);\r\n }\r\n\r\n const hit = cache.get(key);\r\n if (hit && now < hit.expiresAt) {\r\n return this.cloneResponse(hit.value);\r\n }\r\n\r\n // If refresh exists\r\n const group = inFlight.get(key);\r\n if (group) {\r\n const h = cache.get(key);\r\n const staleAllowed = !!h && now - h.createdAt <= mc.maxStaleMs;\r\n\r\n // stale-while-revalidate: serve old immediately\r\n if (h && staleAllowed) {\r\n return this.cloneResponse(h.value);\r\n }\r\n\r\n // No cache allowed: followers must \"join\" or be rejected\r\n const age = now - group.windowStartMs;\r\n if (age > mc.followerTimeoutMs) {\r\n const err = new Error(`Follower window closed for key=${key}`);\r\n (err as any).name = \"FollowerWindowClosedError\";\r\n throw err;\r\n }\r\n\r\n if (group.waiters >= mc.maxWaiters) {\r\n const err = new Error(`Too many followers for key=${key}`);\r\n (err as any).name = \"TooManyWaitersError\";\r\n throw err;\r\n }\r\n\r\n group.waiters += 1;\r\n try {\r\n const res = await group.promise;\r\n return this.cloneResponse(res);\r\n } finally {\r\n group.waiters -= 1;\r\n }\r\n }\r\n\r\n // Become leader\r\n const prev = cache.get(key);\r\n const prevStaleAllowed = !!prev && now - prev.createdAt <= mc.maxStaleMs;\r\n\r\n const promise = (async () => {\r\n const res = await this.fetchWithLeaderRetry(req);\r\n\r\n if (res.status >= 200 && res.status < 300) {\r\n this.evictIfNeeded(cache, mc.maxEntries);\r\n const t = Date.now();\r\n cache.set(key, {\r\n value: this.cloneResponse(res),\r\n createdAt: t,\r\n expiresAt: t + mc.ttlMs,\r\n });\r\n }\r\n\r\n return res;\r\n })();\r\n\r\n const newGroup: InFlightGroup = {\r\n promise,\r\n windowStartMs: Date.now(),\r\n waiters: 0,\r\n };\r\n\r\n inFlight.set(key, newGroup);\r\n\r\n try {\r\n const res = await promise;\r\n\r\n // if not 2xx and we have stale allowed -> serve prev instead\r\n if (!(res.status >= 200 && res.status < 300) && prev && prevStaleAllowed) {\r\n return this.cloneResponse(prev.value);\r\n }\r\n\r\n return this.cloneResponse(res);\r\n } catch (err) {\r\n if (prev && prevStaleAllowed) {\r\n this.emit(\"microcache:refresh_failed\", { key, url: req.url, error: err });\r\n return this.cloneResponse(prev.value);\r\n }\r\n throw err;\r\n } finally {\r\n inFlight.delete(key);\r\n }\r\n }\r\n\r\n private async existingPipeline(req: ResilientRequest): Promise<ResilientResponse> {\r\n const requestId = genRequestId();\r\n\r\n try {\r\n await this.limiter.acquire();\r\n } catch (err) {\r\n this.emit(\"request:rejected\", { requestId, request: req, error: err });\r\n throw err;\r\n }\r\n\r\n const start = Date.now();\r\n this.emit(\"request:start\", { requestId, request: req });\r\n\r\n try {\r\n const res = await doHttpRequest(req, this.requestTimeoutMs);\r\n const durationMs = Date.now() - start;\r\n this.emit(\"request:success\", { requestId, request: req, status: res.status, durationMs });\r\n return res;\r\n } catch (err) {\r\n const durationMs = Date.now() - start;\r\n this.emit(\"request:failure\", { requestId, request: req, error: err, durationMs });\r\n throw err;\r\n } finally {\r\n this.limiter.release();\r\n }\r\n }\r\n\r\n snapshot(): { inFlight: number; queueDepth: number } {\r\n const s = this.limiter.snapshot();\r\n return { inFlight: s.inFlight, queueDepth: s.queueDepth };\r\n }\r\n}\r\n","export abstract class ResilientHttpError extends Error {\r\n public readonly name: string;\r\n constructor(message: string) {\r\n super(message);\r\n this.name = this.constructor.name;\r\n }\r\n}\r\n\r\nexport class QueueFullError extends ResilientHttpError {\r\n constructor(public readonly maxQueue: number) {\r\n super(`Queue is full (maxQueue=${maxQueue}).`);\r\n }\r\n}\r\n\r\nexport class QueueTimeoutError extends ResilientHttpError {\r\n constructor(public readonly enqueueTimeoutMs: number) {\r\n super(`Queue wait exceeded (enqueueTimeoutMs=${enqueueTimeoutMs}).`);\r\n }\r\n}\r\n\r\nexport class RequestTimeoutError extends ResilientHttpError {\r\n constructor(public readonly requestTimeoutMs: number) {\r\n super(`Request timed out (requestTimeoutMs=${requestTimeoutMs}).`);\r\n }\r\n}\r\n\r\n\r\n\r\nexport class UpstreamError extends ResilientHttpError {\r\n constructor(public readonly status: number) {\r\n super(`Upstream returned error status=${status}.`);\r\n }\r\n}\r\n","// src/limiter.ts\r\nimport { QueueFullError, QueueTimeoutError } from \"./errors.js\";\r\n\r\ntype ResolveFn = () => void;\r\ntype RejectFn = (err: unknown) => void;\r\n\r\ninterface Waiter {\r\n resolve: ResolveFn;\r\n reject: RejectFn;\r\n timer: NodeJS.Timeout;\r\n}\r\n\r\n/**\r\n * A simple in-process concurrency limiter with a bounded FIFO queue.\r\n *\r\n * - At most maxInFlight \"permits\" can be held concurrently.\r\n * - If no permit is available, callers are queued up to maxQueue.\r\n * - If queue is full => reject immediately (QueueFullError).\r\n * - If a caller waits too long in the queue => reject (QueueTimeoutError).\r\n *\r\n * This is intentionally process-local: no persistence, no external coordination.\r\n */\r\nexport class ConcurrencyLimiter {\r\n private readonly maxInFlight: number;\r\n private readonly maxQueue: number;\r\n private readonly enqueueTimeoutMs: number;\r\n\r\n private inFlight = 0;\r\n private queue: Waiter[] = [];\r\n\r\n constructor(opts: { maxInFlight: number; maxQueue: number; enqueueTimeoutMs: number }) {\r\n if (!Number.isFinite(opts.maxInFlight) || opts.maxInFlight <= 0) {\r\n throw new Error(`maxInFlight must be > 0 (got ${opts.maxInFlight})`);\r\n }\r\n if (!Number.isFinite(opts.maxQueue) || opts.maxQueue < 0) {\r\n throw new Error(`maxQueue must be >= 0 (got ${opts.maxQueue})`);\r\n }\r\n if (!Number.isFinite(opts.enqueueTimeoutMs) || opts.enqueueTimeoutMs <= 0) {\r\n throw new Error(`enqueueTimeoutMs must be > 0 (got ${opts.enqueueTimeoutMs})`);\r\n }\r\n\r\n this.maxInFlight = opts.maxInFlight;\r\n this.maxQueue = opts.maxQueue;\r\n this.enqueueTimeoutMs = opts.enqueueTimeoutMs;\r\n }\r\n\r\n /**\r\n * Acquire a permit. Resolves once you are allowed to proceed.\r\n * MUST be followed by `release()` exactly once.\r\n */\r\n acquire(): Promise<void> {\r\n // Fast path: permit available\r\n if (this.inFlight < this.maxInFlight) {\r\n this.inFlight += 1;\r\n return Promise.resolve();\r\n }\r\n\r\n // Queue disabled or full\r\n if (this.maxQueue === 0 || this.queue.length >= this.maxQueue) {\r\n return Promise.reject(new QueueFullError(this.maxQueue));\r\n }\r\n\r\n // Enqueue and wait\r\n return new Promise<void>((resolve, reject) => {\r\n const timer = setTimeout(() => {\r\n // On timeout, remove from queue if still present\r\n const idx = this.queue.findIndex((w) => w.resolve === resolve);\r\n if (idx >= 0) {\r\n const [w] = this.queue.splice(idx, 1);\r\n clearTimeout(w.timer);\r\n }\r\n reject(new QueueTimeoutError(this.enqueueTimeoutMs));\r\n }, this.enqueueTimeoutMs);\r\n\r\n this.queue.push({ resolve, reject, timer });\r\n });\r\n }\r\n\r\n /**\r\n * Release a permit. Always call this in a `finally` block.\r\n */\r\n release(): void {\r\n if (this.inFlight <= 0) {\r\n // Defensive: indicates a bug in caller usage.\r\n throw new Error(\"release() called when inFlight is already 0\");\r\n }\r\n\r\n // If someone is waiting, hand off permit directly without reducing inFlight.\r\n const next = this.queue.shift();\r\n if (next) {\r\n clearTimeout(next.timer);\r\n // Permit is transferred to the next waiter; inFlight stays the same.\r\n next.resolve();\r\n return;\r\n }\r\n\r\n // No waiters => reduce inFlight\r\n this.inFlight -= 1;\r\n }\r\n\r\n snapshot(): { inFlight: number; queueDepth: number; maxInFlight: number; maxQueue: number } {\r\n return {\r\n inFlight: this.inFlight,\r\n queueDepth: this.queue.length,\r\n maxInFlight: this.maxInFlight,\r\n maxQueue: this.maxQueue,\r\n };\r\n }\r\n}\r\n","// src/http.ts\r\nimport { request as undiciRequest } from \"undici\";\r\nimport { RequestTimeoutError } from \"./errors.js\";\r\nimport type { ResilientRequest, ResilientResponse } from \"./types.js\";\r\n\r\nfunction normalizeHeaders(headers: any): Record<string, string> {\r\n const out: Record<string, string> = {};\r\n if (!headers) return out;\r\n\r\n // undici headers are an object-like structure (headers: Record<string, string | string[]>)\r\n for (const [k, v] of Object.entries(headers)) {\r\n if (Array.isArray(v)) out[k.toLowerCase()] = v.join(\", \");\r\n else if (typeof v === \"string\") out[k.toLowerCase()] = v;\r\n else out[k.toLowerCase()] = String(v);\r\n }\r\n return out;\r\n}\r\n\r\n/**\r\n * Execute a single HTTP request with a hard timeout using AbortController.\r\n * No retries. No breaker. Just raw outbound I/O with a timeout.\r\n */\r\nexport async function doHttpRequest(\r\n req: ResilientRequest,\r\n requestTimeoutMs: number\r\n): Promise<ResilientResponse> {\r\n const ac = new AbortController();\r\n const timer = setTimeout(() => ac.abort(), requestTimeoutMs);\r\n\r\n try {\r\n const res = await undiciRequest(req.url, {\r\n method: req.method,\r\n headers: req.headers,\r\n body: req.body as any,\r\n signal: ac.signal,\r\n });\r\n\r\n const body = await res.body.arrayBuffer();\r\n return {\r\n status: res.statusCode,\r\n headers: normalizeHeaders(res.headers),\r\n body: new Uint8Array(body),\r\n };\r\n } catch (err: any) {\r\n // undici throws AbortError on abort\r\n if (err?.name === \"AbortError\") {\r\n throw new RequestTimeoutError(requestTimeoutMs);\r\n }\r\n throw err;\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n","// demo/loadgen.ts\r\nimport { ResilientHttpClient } from \"../src/client.js\";\r\n\r\nfunction sleep(ms: number) {\r\n return new Promise((r) => setTimeout(r, ms));\r\n}\r\n\r\nconst client = new ResilientHttpClient({\r\n maxInFlight: 5,\r\n maxQueue: 20,\r\n enqueueTimeoutMs: 100,\r\n requestTimeoutMs: 4000,\r\n\r\n microCache: {\r\n enabled: true,\r\n ttlMs: 1000,\r\n maxStaleMs: 800,\r\n maxEntries: 100,\r\n\r\n // ⭐ important demo knobs\r\n maxWaiters: 10,\r\n followerTimeoutMs: 5000,\r\n\r\n retry: {\r\n maxAttempts: 3,\r\n baseDelayMs: 50,\r\n maxDelayMs: 200,\r\n retryOnStatus: [503],\r\n },\r\n },\r\n});\r\n\r\n// Observe behavior\r\nclient.on(\"microcache:retry\", (e) =>\r\n console.log(\"[retry]\", e.reason, `attempt ${e.attempt}`)\r\n);\r\nclient.on(\"microcache:waiters_full\", () =>\r\n console.log(\"[shed] follower queue full\")\r\n);\r\nclient.on(\"microcache:follower_window_closed\", () =>\r\n console.log(\"[shed] follower window closed\")\r\n);\r\nclient.on(\"breaker:state\", (e) =>\r\n console.log(`[breaker] ${e.key}: ${e.from} -> ${e.to}`)\r\n);\r\n\r\nasync function burst(label: string, n: number) {\r\n console.log(`\\n=== burst: ${label} (${n} requests) ===`);\r\n\r\n await Promise.allSettled(\r\n Array.from({ length: n }, async (_, i) => {\r\n try {\r\n const res = await client.request({\r\n method: \"GET\",\r\n url: \"http://localhost:3001/data\",\r\n });\r\n\r\n console.log(\r\n `[${label} #${i}]`,\r\n res.status,\r\n Buffer.from(res.body).toString()\r\n );\r\n } catch (err: any) {\r\n console.log(`[${label} #${i}] ERROR`, err.name);\r\n }\r\n })\r\n );\r\n}\r\n\r\n(async () => {\r\n // Cold start: singleflight + slow upstream\r\n await burst(\"cold-start\", 10);\r\n\r\n // Cached: zero upstream hits\r\n await sleep(500);\r\n await burst(\"cached\", 10);\r\n\r\n // TTL expires → refresh with stale serve\r\n await sleep(1200);\r\n await burst(\"refresh-with-stale\", 10);\r\n\r\n // Failure window → leader retries, followers protected\r\n await sleep(900);\r\n await burst(\"failure\", 20);\r\n\r\n // Recovery\r\n await sleep(4000);\r\n await burst(\"recovered\", 10);\r\n\r\n console.log(\"\\nDone.\");\r\n})();\r\n"],"mappings":"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","names":["undiciRequest","sleep"]}
@@ -1,2 +0,0 @@
1
-
2
- export { }
@@ -1,2 +0,0 @@
1
-
2
- export { }