diff --git a/logging_rate_limit_observer.ts b/logging_rate_limit_observer.ts new file mode 100644 index 0000000..61bfbb7 --- /dev/null +++ b/logging_rate_limit_observer.ts @@ -0,0 +1,66 @@ +import { type RateLimitInfo, rateLimitUsagePct } from "./rate_limit_headers.ts"; +import type { RateLimitInfoCallback } from "./rate_limit_fetch.ts"; + +/** + * Default usage thresholds (80%, 90%, 95%) at which the observer emits a log. + */ +export const DEFAULT_RATE_LIMIT_THRESHOLDS: readonly number[] = [ + 0.8, + 0.9, + 0.95, +]; + +/** + * Configuration for the LoggingRateLimitObserver. + */ +export interface LoggingRateLimitObserverOptions { + /** Usage fractions (0.0 - 1.0) that should produce a log line. */ + thresholds?: readonly number[]; + /** + * Function used to emit the log line. Defaults to `console.warn`. + * Useful when injecting a structured logger. + */ + log?: (message: string) => void; +} + +/** + * Returns a ready-to-use `onRateLimitInfo` callback that logs a warning each + * time rate limit usage crosses one of the configured thresholds. + * + * Example: + * + * ```ts + * import { Client } from "@getlago/lago-javascript-client"; + * import { loggingRateLimitObserver } from "@getlago/lago-javascript-client"; + * + * const client = Client(apiKey, { + * rateLimitRetry: { onRateLimitInfo: loggingRateLimitObserver() }, + * }); + * ``` + */ +export function loggingRateLimitObserver( + options: LoggingRateLimitObserverOptions = {}, +): RateLimitInfoCallback { + const thresholds = [...(options.thresholds ?? DEFAULT_RATE_LIMIT_THRESHOLDS)] + .sort((a, b) => b - a); // descending + // deno-lint-ignore no-console + const log = options.log ?? ((m: string) => console.warn(m)); + + return function observe(info: RateLimitInfo): void { + const pct = rateLimitUsagePct(info); + if (pct === null) return; + + for (const threshold of thresholds) { + if (pct >= threshold) { + log( + `Lago rate limit at ${ + (pct * 100).toFixed(0) + }% (limit=${info.limit}, ` + + `remaining=${info.remaining}, reset=${info.reset}s, ` + + `${info.method} ${info.url})`, + ); + return; + } + } + }; +} diff --git a/mod.ts b/mod.ts index bd9af26..7ff7151 100644 --- a/mod.ts +++ b/mod.ts @@ -64,7 +64,22 @@ export async function getLagoError(error: any) { // Rate limit exports export { LagoRateLimitError } from "./rate_limit_error.ts"; -export { parseRateLimitHeaders, type RateLimitHeaders } from "./rate_limit_headers.ts"; -export { createRateLimitFetch, type RateLimitFetchConfig } from "./rate_limit_fetch.ts"; +export { + parseRateLimitHeaders, + parseRateLimitInfo, + type RateLimitHeaders, + type RateLimitInfo, + rateLimitUsagePct, +} from "./rate_limit_headers.ts"; +export { + createRateLimitFetch, + type RateLimitFetchConfig, + type RateLimitInfoCallback, +} from "./rate_limit_fetch.ts"; +export { + DEFAULT_RATE_LIMIT_THRESHOLDS, + loggingRateLimitObserver, + type LoggingRateLimitObserverOptions, +} from "./logging_rate_limit_observer.ts"; export * from "./openapi/client.ts"; diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts index 13ddfa8..000813e 100644 --- a/rate_limit_fetch.ts +++ b/rate_limit_fetch.ts @@ -1,9 +1,20 @@ import { LagoRateLimitError } from "./rate_limit_error.ts"; import { parseRateLimitHeaders, - type RateLimitHeaders, + parseRateLimitInfo, + type RateLimitInfo, } from "./rate_limit_headers.ts"; +/** + * Callback invoked after every successful response with parsed rate limit + * headers. Use this to build observability around the rate limit (warn at + * thresholds, emit metrics, etc.). + * + * Errors thrown by the callback are caught and logged so they cannot break + * the underlying request flow. + */ +export type RateLimitInfoCallback = (info: RateLimitInfo) => void; + /** * Configuration for rate limit fetch behavior */ @@ -14,6 +25,11 @@ export interface RateLimitFetchConfig { retryOnRateLimit?: boolean; /** Maximum delay in milliseconds before a retry (default: 20000) */ maxRetryDelay?: number; + /** + * Optional callback invoked after every successful (non-429) response + * with parsed rate limit headers. See `RateLimitInfoCallback`. + */ + onRateLimitInfo?: RateLimitInfoCallback; } /** @@ -27,6 +43,7 @@ export function createRateLimitFetch( const maxRetries = config.maxRetries ?? 3; const retryOnRateLimit = config.retryOnRateLimit ?? true; const maxRetryDelay = config.maxRetryDelay ?? 20_000; + const onRateLimitInfo = config.onRateLimitInfo; return async function rateLimitFetch( input: RequestInfo | URL, @@ -61,7 +78,12 @@ export function createRateLimitFetch( continue; // Retry } - // Success or non-rate-limit error - return the response + // Success: emit observability info. Non-2xx, non-429 responses are + // returned as-is without invoking the callback because their headers + // do not carry useful rate limit context. + if (onRateLimitInfo && response.ok) { + emitRateLimitInfo(onRateLimitInfo, response, input, init); + } return response; } catch (error) { lastError = error; @@ -82,6 +104,40 @@ export function createRateLimitFetch( }; } +/** + * Invoke the configured callback with parsed rate limit info, swallowing any + * exception so a buggy observer cannot break the request. + */ +function emitRateLimitInfo( + callback: RateLimitInfoCallback, + response: Response, + input: RequestInfo | URL, + init?: RequestInit, +): void { + try { + // Honor the method on a Request input when init.method is undefined + // (a valid fetch signature: fetch(new Request(url, { method: 'POST' }))). + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const url = requestUrl(input); + const info = parseRateLimitInfo(response.headers, method, url); + if (info === null) return; + callback(info); + } catch (err) { + // Never let observability break the request flow. + // deno-lint-ignore no-console + console.warn("Lago: onRateLimitInfo callback raised:", err); + } +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + // Request + return (input as Request).url; +} + /** * Calculate wait time before retry * Uses the exact reset time from headers if available, otherwise exponential backoff diff --git a/rate_limit_headers.ts b/rate_limit_headers.ts index b1de8d3..110bc4c 100644 --- a/rate_limit_headers.ts +++ b/rate_limit_headers.ts @@ -7,6 +7,32 @@ export interface RateLimitHeaders { reset: number | null; } +/** + * Parsed rate limit headers plus the request context they came from. + * + * Delivered to the `onRateLimitInfo` callback after every successful request + * so callers can build observability around the rate limit (warn at thresholds, + * emit metrics, etc.). + */ +export interface RateLimitInfo extends RateLimitHeaders { + /** HTTP method of the call (GET, POST, ...). */ + method: string; + /** Request URL. */ + url: string; +} + +/** + * Returns the fraction of the rate limit currently used in [0.0, 1.0], + * or `null` when the headers aren't usable (missing limit, zero limit, + * missing remaining). + */ +export function rateLimitUsagePct(info: RateLimitHeaders): number | null { + if (info.limit == null || info.remaining == null || info.limit <= 0) { + return null; + } + return 1 - info.remaining / info.limit; +} + /** * Extract rate limit information from response headers */ @@ -18,6 +44,26 @@ export function parseRateLimitHeaders(headers: Headers): RateLimitHeaders { }; } +/** + * Returns a `RateLimitInfo` (headers + request context), or `null` when no + * `x-ratelimit-*` headers are present (e.g. self-hosted Lago instance with + * limits disabled). Useful for skipping observability emission entirely when + * there's nothing to report. + */ +export function parseRateLimitInfo( + headers: Headers, + method: string, + url: string, +): RateLimitInfo | null { + const parsed = parseRateLimitHeaders(headers); + if ( + parsed.limit == null && parsed.remaining == null && parsed.reset == null + ) { + return null; + } + return { ...parsed, method, url }; +} + /** * Helper to parse a header value as a number */ diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts index 7aca6b3..4d42aa2 100644 --- a/tests/rate_limit.test.ts +++ b/tests/rate_limit.test.ts @@ -1,13 +1,22 @@ import { assertEquals } from "../dev_deps.ts"; import { Client, + createRateLimitFetch, LagoRateLimitError, + loggingRateLimitObserver, parseRateLimitHeaders, - createRateLimitFetch, + parseRateLimitInfo, + type RateLimitInfo, + rateLimitUsagePct, } from "../mod.ts"; // Simple fetch mock helper (replaces broken mock_fetch library) -function createMockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise): typeof fetch { +function createMockFetch( + handler: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise, +): typeof fetch { return (input: RequestInfo | URL, init?: RequestInit) => { return Promise.resolve(handler(input, init)); }; @@ -260,3 +269,268 @@ Deno.test("LagoRateLimitError is instanceof Error", () => { assertEquals(error instanceof Error, true); assertEquals(error instanceof LagoRateLimitError, true); }); + +// --------------------------------------------------------------------------- +// Rate limit observability +// --------------------------------------------------------------------------- + +Deno.test("rateLimitUsagePct returns the fraction used", () => { + assertEquals( + rateLimitUsagePct({ limit: 100, remaining: 20, reset: 5 }), + 0.8, + ); + assertEquals( + rateLimitUsagePct({ limit: 100, remaining: 0, reset: 5 }), + 1, + ); +}); + +Deno.test("rateLimitUsagePct returns null when headers are unusable", () => { + assertEquals( + rateLimitUsagePct({ limit: null, remaining: 20, reset: 5 }), + null, + ); + assertEquals( + rateLimitUsagePct({ limit: 100, remaining: null, reset: 5 }), + null, + ); + assertEquals( + rateLimitUsagePct({ limit: 0, remaining: 0, reset: 5 }), + null, + ); +}); + +Deno.test("parseRateLimitInfo returns null when no headers are present", () => { + const headers = new Headers({ "content-type": "application/json" }); + assertEquals(parseRateLimitInfo(headers, "GET", "https://x"), null); +}); + +Deno.test("parseRateLimitInfo returns populated info when headers exist", () => { + const headers = new Headers({ + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "42", + "x-ratelimit-reset": "5", + }); + + const info = parseRateLimitInfo(headers, "POST", "https://x"); + assertEquals(info, { + limit: 100, + remaining: 42, + reset: 5, + method: "POST", + url: "https://x", + }); +}); + +Deno.test("onRateLimitInfo fires after a successful response", async () => { + const captured: RateLimitInfo[] = []; + + const mockFetch = createMockFetch(() => + new Response('{"ok": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "20", + "x-ratelimit-reset": "5", + }, + }) + ); + + const fetchWithLimits = createRateLimitFetch(mockFetch, { + onRateLimitInfo: (info) => captured.push(info), + }); + + await fetchWithLimits("https://example.com/api", { method: "POST" }); + + assertEquals(captured.length, 1); + assertEquals(captured[0].limit, 100); + assertEquals(captured[0].remaining, 20); + assertEquals(captured[0].reset, 5); + assertEquals(captured[0].method, "POST"); + assertEquals(captured[0].url, "https://example.com/api"); +}); + +Deno.test( + "onRateLimitInfo does not fire when rate limit headers are absent", + async () => { + let called = 0; + + const mockFetch = createMockFetch(() => + new Response('{"ok": true}', { status: 200 }) + ); + const fetchWithLimits = createRateLimitFetch(mockFetch, { + onRateLimitInfo: () => called++, + }); + + await fetchWithLimits("https://example.com/api"); + assertEquals(called, 0); + }, +); + +Deno.test( + "onRateLimitInfo errors are swallowed so the request still returns", + async () => { + const mockFetch = createMockFetch(() => + new Response('{"ok": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "1", + "x-ratelimit-reset": "5", + }, + }) + ); + const fetchWithLimits = createRateLimitFetch(mockFetch, { + onRateLimitInfo: () => { + throw new Error("intentional"); + }, + }); + + const response = await fetchWithLimits("https://example.com/api"); + assertEquals(response.status, 200); + }, +); + +Deno.test( + "onRateLimitInfo fires once after a 429-then-200 retry sequence", + async () => { + const captured: RateLimitInfo[] = []; + let calls = 0; + + const mockFetch = createMockFetch(() => { + calls++; + if (calls === 1) { + return new Response("{}", { + status: 429, + headers: { "x-ratelimit-reset": "0" }, + }); + } + return new Response('{"ok": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "50", + "x-ratelimit-reset": "5", + }, + }); + }); + + const fetchWithLimits = createRateLimitFetch(mockFetch, { + maxRetryDelay: 0, // skip the wait + onRateLimitInfo: (info) => captured.push(info), + }); + + await fetchWithLimits("https://example.com/api"); + assertEquals(captured.length, 1); + assertEquals(captured[0].remaining, 50); + }, +); + +Deno.test("loggingRateLimitObserver logs above threshold", () => { + const messages: string[] = []; + const observer = loggingRateLimitObserver({ + thresholds: [0.8, 0.9, 0.95], + log: (m) => messages.push(m), + }); + + observer({ + limit: 100, + remaining: 4, + reset: 10, + method: "GET", + url: "https://x", + }); + + assertEquals(messages.length, 1); + assertEquals(messages[0].includes("96%"), true); +}); + +Deno.test("loggingRateLimitObserver is silent below threshold", () => { + const messages: string[] = []; + const observer = loggingRateLimitObserver({ + thresholds: [0.8], + log: (m) => messages.push(m), + }); + + observer({ + limit: 100, + remaining: 50, + reset: 10, + method: "GET", + url: "https://x", + }); + + assertEquals(messages.length, 0); +}); + +Deno.test("loggingRateLimitObserver is silent when usage is unavailable", () => { + const messages: string[] = []; + const observer = loggingRateLimitObserver({ + log: (m) => messages.push(m), + }); + + observer({ + limit: null, + remaining: null, + reset: null, + method: "GET", + url: "https://x", + }); + + assertEquals(messages.length, 0); +}); + +Deno.test( + "onRateLimitInfo reads method from a Request input when init is undefined", + async () => { + const captured: RateLimitInfo[] = []; + + const mockFetch = createMockFetch(() => + new Response('{"ok": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "5", + }, + }) + ); + const fetchWithLimits = createRateLimitFetch(mockFetch, { + onRateLimitInfo: (info) => captured.push(info), + }); + + const request = new Request("https://example.com/api", { + method: "DELETE", + }); + await fetchWithLimits(request); + + assertEquals(captured.length, 1); + assertEquals(captured[0].method, "DELETE"); + assertEquals(captured[0].url, "https://example.com/api"); + }, +); + +Deno.test( + "onRateLimitInfo does not fire on non-2xx, non-429 responses", + async () => { + const captured: RateLimitInfo[] = []; + + const mockFetch = createMockFetch(() => + new Response("oops", { + status: 500, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "5", + }, + }) + ); + const fetchWithLimits = createRateLimitFetch(mockFetch, { + onRateLimitInfo: (info) => captured.push(info), + }); + + const response = await fetchWithLimits("https://example.com/api"); + assertEquals(response.status, 500); + assertEquals(captured.length, 0); + }, +);