From 0863a62e380adbb0a2fda0097eb86255b89681d1 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Tue, 5 May 2026 13:02:15 +0200 Subject: [PATCH 1/2] feat: expose rate limit headers via onRateLimitInfo callback Surface x-ratelimit-* headers from successful responses so callers can build observability around the rate limit (warn at thresholds, emit metrics) without inspecting internals. - Add RateLimitInfo type with parseRateLimitInfo + rateLimitUsagePct helpers - Add onRateLimitInfo option on RateLimitFetchConfig - Parse and emit headers on every non-429 response in createRateLimitFetch - Ship loggingRateLimitObserver as a zero-config observer (defaults: 80/90/95%) - Callback errors are caught and logged so they cannot break requests - Backward-compatible: callback is optional, default behavior unchanged --- logging_rate_limit_observer.ts | 66 ++++++++++ mod.ts | 19 ++- rate_limit_fetch.ts | 54 +++++++- rate_limit_headers.ts | 46 +++++++ tests/rate_limit.test.ts | 223 ++++++++++++++++++++++++++++++++- 5 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 logging_rate_limit_observer.ts 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..887d438 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,10 @@ export function createRateLimitFetch( continue; // Retry } - // Success or non-rate-limit error - return the response + // Success or non-rate-limit error: emit observability info and return + if (onRateLimitInfo) { + emitRateLimitInfo(onRateLimitInfo, response, input, init); + } return response; } catch (error) { lastError = error; @@ -82,6 +102,36 @@ 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 { + const method = (init?.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..8107575 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,213 @@ 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); +}); From 0764a5ab1e9dd66609d270853fa6d478c12467c9 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Tue, 5 May 2026 16:40:45 +0200 Subject: [PATCH 2/2] fix: align rate limit observability behavior with other Lago SDKs Address review feedback from @vincent-pochet on PR #86: - Honor Request.method when input is a Request and init.method is undefined (a valid fetch signature). Previously this fell through to 'GET' regardless of the actual method on the Request. - Only emit on_rate_limit_info on 2xx responses. Non-429 errors no longer trigger the callback. This matches the comment on the call site, the docstring on the option, and the behavior of the Python, Go, Ruby, and Rust SDKs. - Add tests for the Request input case and for the 5xx no-emit case. --- rate_limit_fetch.ts | 12 ++++++--- tests/rate_limit.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts index 887d438..000813e 100644 --- a/rate_limit_fetch.ts +++ b/rate_limit_fetch.ts @@ -78,8 +78,10 @@ export function createRateLimitFetch( continue; // Retry } - // Success or non-rate-limit error: emit observability info and return - if (onRateLimitInfo) { + // 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; @@ -113,7 +115,11 @@ function emitRateLimitInfo( init?: RequestInit, ): void { try { - const method = (init?.method ?? "GET").toUpperCase(); + // 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; diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts index 8107575..4d42aa2 100644 --- a/tests/rate_limit.test.ts +++ b/tests/rate_limit.test.ts @@ -479,3 +479,58 @@ Deno.test("loggingRateLimitObserver is silent when usage is unavailable", () => 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); + }, +);