UNPKG

datadog-ux-utils

Version:

Datadog RUM focused UX & performance toolkit: API guards (retry, breaker, rate), React telemetry (error boundary, profiler, Suspense), web vitals & resource observers, offline queues.

1 lines 14 kB
{"version":3,"file":"rateGuard-KzvlZG_6.cjs","sources":["../src/api/rateGuard.ts"],"sourcesContent":["/**\n * @file rateGuard.ts\n * @description Guards against runaway or excessive API calls by rate-limiting requests and providing overflow strategies.\n */\nimport { addAction } from \"../datadog.ts\";\nimport { getFlags } from \"../flags.ts\";\n\n/**\n * Strategy when limit is exceeded:\n * - \"block\": immediately reject and do NOT call the API (default)\n * - \"queue\": delay the call until the block window ends (keeps pressure off the backend, but may increase UX delay)\n * - \"drop\": silently drop and resolve to undefined (not recommended unless the caller handles \"undefined\")\n */\n/**\n * Strategy when API rate limit is exceeded.\n * - \"block\": immediately reject and do NOT call the API (default)\n * - \"queue\": delay the call until the block window ends\n * - \"drop\": silently drop and resolve to undefined\n */\nexport type GuardOverflowStrategy = \"block\" | \"queue\" | \"drop\";\n\n/**\n * Configuration options for the API rate guard utility.\n */\nexport type ApiRateGuardConfig = {\n windowMs: number; // e.g., 2000 (2s)\n maxRequests: number; // e.g., 5 (per window)\n blockDurationMs?: number; // e.g., 1500 — how long to block after exceeding (default = windowMs)\n reportDebounceMs?: number; // minimum time between DD reports for the same key (default 5000)\n sampleRate?: number; // % for DataDog action sampling (default 100)\n\n // How to group requests into a “key.” Default: <METHOD> <pathname> (ignores querystring)\n keyFn?: (input: RequestInfo | URL, init?: RequestInit) => string;\n\n // Whether to include failed requests in the counting. Default true.\n countOnFailure?: boolean;\n\n // What to do when the limit is exceeded\n overflowStrategy?: GuardOverflowStrategy;\n\n // Optional: filter which keys are guarded (e.g., only /api/*)\n allowKey?: (key: string) => boolean;\n};\n\ntype Bucket = {\n // timestamps in ms since epoch for requests within window\n hits: number[];\n blockedUntil: number; // epoch ms; 0 if not blocked\n lastReportAt: number; // epoch ms; 0 if never reported\n // queue of deferred resolvers if strategy === 'queue'\n waiters?: Array<() => void>;\n};\n\nconst DEFAULTS: Required<\n Pick<\n ApiRateGuardConfig,\n | \"blockDurationMs\"\n | \"reportDebounceMs\"\n | \"sampleRate\"\n | \"countOnFailure\"\n | \"overflowStrategy\"\n >\n> = {\n blockDurationMs: 0, // 0 => use windowMs at runtime\n reportDebounceMs: 5000,\n sampleRate: 100,\n countOnFailure: true,\n overflowStrategy: \"block\",\n};\n\n// In-memory moving windows per key\n/**\n * API rate guard for limiting requests per time window.\n *\n * @example\n * // Limit to 5 requests per 2 seconds\n * const guard = new ApiRateGuard({ windowMs: 2000, maxRequests: 5 });\n * await guard.guardFetch('/api/patients');\n */\nexport class ApiRateGuard {\n private cfg: Required<ApiRateGuardConfig>;\n private buckets = new Map<string, Bucket>();\n\n constructor(config: ApiRateGuardConfig) {\n const keyFn = config.keyFn ?? defaultKeyFn;\n const blockDurationMs = config.blockDurationMs ?? 0;\n this.cfg = {\n ...config,\n keyFn,\n blockDurationMs: blockDurationMs || config.windowMs,\n reportDebounceMs: config.reportDebounceMs ?? DEFAULTS.reportDebounceMs,\n sampleRate: config.sampleRate ?? DEFAULTS.sampleRate,\n countOnFailure: config.countOnFailure ?? DEFAULTS.countOnFailure,\n overflowStrategy: config.overflowStrategy ?? DEFAULTS.overflowStrategy,\n allowKey: config.allowKey ?? (() => true),\n };\n }\n\n /**\n * Wrap a fetch call. If the guard blocks, it throws ApiRunawayBlockedError\n * (or queues/drops according to strategy).\n */\n async guardFetch(\n input: RequestInfo | URL,\n init?: RequestInit\n ): Promise<Response> {\n const key = this.cfg.keyFn(input, init);\n if (!this.cfg.allowKey(key)) {\n return fetch(input, init); // not guarded\n }\n\n await this.beforeRequest(key);\n\n let resp: Response;\n try {\n resp = await fetch(input, init);\n // Count successful requests\n this.afterRequest(key, true);\n return resp;\n } catch (err) {\n // Optionally count failed requests too\n this.afterRequest(key, this.cfg.countOnFailure);\n throw err;\n }\n }\n\n /**\n * Generic guard for arbitrary async API calls (Axios, graphql-request, etc.)\n * Usage:\n * await apiGuard.guard('POST /api/items', () => axios.post(...));\n */\n async guard<T>(key: string, call: () => Promise<T>): Promise<T | undefined> {\n if (!this.cfg.allowKey(key)) return call();\n\n await this.beforeRequest(key);\n\n try {\n const res = await call();\n this.afterRequest(key, true);\n return res;\n } catch (e) {\n this.afterRequest(key, this.cfg.countOnFailure);\n throw e;\n }\n }\n\n /** Clear counters for testing or when navigating away. */\n reset(key?: string) {\n if (key) this.buckets.delete(key);\n else this.buckets.clear();\n }\n\n /* ----------------- internals ----------------- */\n\n private getBucket(key: string): Bucket {\n let b = this.buckets.get(key);\n if (!b) {\n b = { hits: [], blockedUntil: 0, lastReportAt: 0 };\n this.buckets.set(key, b);\n }\n return b;\n }\n\n private async beforeRequest(key: string) {\n const now = Date.now();\n\n // Kill switch: if disabled, do nothing (no counting, no blocking, no reports)\n if (!getFlags().guardEnabled) return;\n\n const b = this.getBucket(key);\n this.prune(now, b);\n\n if (b.blockedUntil > now) {\n if (this.cfg.overflowStrategy === \"queue\") {\n await this.waitUntil(b.blockedUntil, b);\n // fallthrough; request proceeds after block expires\n } else if (this.cfg.overflowStrategy === \"drop\") {\n this.maybeReport(key, now, b, /*reason*/ \"blocked_active\");\n return Promise.resolve(undefined as never);\n } else {\n this.maybeReport(key, now, b, \"blocked_active\");\n throw new ApiRunawayBlockedError(key, this.cfg, b);\n }\n }\n\n // Tentatively count the request at the front of the call (protecting backend)\n b.hits.push(now);\n\n // Check if limit exceeded inside the window\n this.prune(now, b);\n if (b.hits.length > this.cfg.maxRequests) {\n // Enter block state and back out this attempt according to strategy\n b.blockedUntil = now + this.cfg.blockDurationMs;\n this.maybeReport(key, now, b, \"threshold_exceeded\");\n\n if (this.cfg.overflowStrategy === \"queue\") {\n // Remove the tentative hit; it will be re-added after waiting\n b.hits.pop();\n await this.waitUntil(b.blockedUntil, b);\n // After waiting, re-add and continue\n b.hits.push(Date.now());\n this.prune(Date.now(), b);\n } else if (this.cfg.overflowStrategy === \"drop\") {\n b.hits.pop();\n return Promise.resolve(undefined as never);\n } else {\n // \"block\"\n b.hits.pop();\n throw new ApiRunawayBlockedError(key, this.cfg, b);\n }\n }\n }\n\n private afterRequest(_key: string, count: boolean) {\n // No-op except we kept the timestamp if `count` is true.\n // If count=false (e.g., failed request and countOnFailure=false),\n // we should remove the last timestamp we pushed.\n if (!count) {\n const b = this.getBucket(_key);\n b.hits.pop();\n }\n }\n\n private prune(now: number, b: Bucket) {\n const wStart = now - this.cfg.windowMs;\n // Remove timestamps older than window start\n while (b.hits.length && b.hits[0] < wStart) b.hits.shift();\n }\n\n private maybeReport(\n key: string,\n now: number,\n b: Bucket,\n reason: \"threshold_exceeded\" | \"blocked_active\"\n ) {\n if (now - b.lastReportAt < this.cfg.reportDebounceMs) return;\n b.lastReportAt = now;\n\n if (passSample(this.cfg.sampleRate)) {\n addAction(\n \"api_runaway_blocked\",\n {\n key,\n reason, // \"threshold_exceeded\" or \"blocked_active\"\n window_ms: this.cfg.windowMs,\n max_requests: this.cfg.maxRequests,\n block_ms: this.cfg.blockDurationMs,\n count_in_window: b.hits.length,\n },\n this.cfg.sampleRate\n );\n }\n }\n\n private waitUntil(ts: number, b: Bucket) {\n if (Date.now() >= ts) return Promise.resolve();\n if (!b.waiters) b.waiters = [];\n return new Promise<void>((resolve) => {\n b.waiters!.push(resolve);\n const delay = ts - Date.now();\n setTimeout(() => {\n // Flush all waiters once block ends\n const w = b.waiters!;\n b.waiters = [];\n w.forEach((fn) => fn());\n }, delay);\n });\n }\n}\n\n/* ----------------- helpers & defaults ----------------- */\n\n/**\n * Error thrown when API requests are blocked by the rate guard.\n */\nexport class ApiRunawayBlockedError extends Error {\n public readonly key: string;\n public readonly until: number;\n public readonly windowMs: number;\n public readonly maxRequests: number;\n constructor(key: string, cfg: Required<ApiRateGuardConfig>, b: Bucket) {\n super(\n `API requests blocked by guard for key \"${key}\" until ${new Date(\n b.blockedUntil\n ).toISOString()}`\n );\n this.name = \"ApiRunawayBlockedError\";\n this.key = key;\n this.until = b.blockedUntil;\n this.windowMs = cfg.windowMs;\n this.maxRequests = cfg.maxRequests;\n }\n}\n\nfunction defaultKeyFn(input: RequestInfo | URL, init?: RequestInit): string {\n // METHOD + PATHNAME (ignore querystring to avoid splitting by params)\n const method = (init?.method ?? \"GET\").toUpperCase();\n try {\n const u =\n typeof input === \"string\"\n ? new URL(input, location.origin)\n : new URL((input as URL).toString(), location.origin);\n return `${method} ${u.pathname}`;\n } catch {\n return `${method} ${String(input)}`;\n }\n}\n\nfunction passSample(pct: number) {\n return Math.random() * 100 < Math.max(0, Math.min(100, Math.round(pct)));\n}\n"],"names":["DEFAULTS","ApiRateGuard","config","keyFn","defaultKeyFn","blockDurationMs","input","init","key","resp","err","call","res","e","b","now","ApiRunawayBlockedError","_key","count","wStart","reason","passSample","addAction","ts","resolve","delay","w","fn","cfg","method","u","pct"],"mappings":"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"}