lago-javascript-client
Version:
Lago JavaScript API Client
112 lines (111 loc) • 4.38 kB
JavaScript
import { LagoRateLimitError } from "./rate_limit_error.js";
import { parseRateLimitHeaders, parseRateLimitInfo, } from "./rate_limit_headers.js";
/**
* Creates a fetch wrapper that handles rate limiting with automatic retry
* Compatible with both Node.js fetch and browser fetch APIs
*/
export function createRateLimitFetch(baseFetch, config = {}) {
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, init) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await baseFetch(input, init);
// Handle 429 responses
if (response.status === 429) {
const headers = parseRateLimitHeaders(response.headers);
const limit = headers.limit ?? -1;
const remaining = headers.remaining ?? 0;
const reset = headers.reset ?? -1;
const error = new LagoRateLimitError(limit, remaining, reset);
if (!retryOnRateLimit) {
throw error;
}
if (attempt === maxRetries) {
throw error; // Max retries reached
}
// Wait before retry
const waitMs = getWaitTime(error, attempt, maxRetryDelay);
await sleep(waitMs);
continue; // Retry
}
// 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;
if (!(error instanceof LagoRateLimitError)) {
throw error; // Not a rate limit error, rethrow immediately
}
if (attempt === maxRetries) {
throw error; // Max retries reached
}
// Will retry on next iteration
}
}
throw lastError;
};
}
/**
* Invoke the configured callback with parsed rate limit info, swallowing any
* exception so a buggy observer cannot break the request.
*/
function emitRateLimitInfo(callback, response, input, init) {
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) {
if (typeof input === "string")
return input;
if (input instanceof URL)
return input.toString();
// Request
return input.url;
}
/**
* Calculate wait time before retry
* Uses the exact reset time from headers if available, otherwise exponential backoff
*/
function getWaitTime(error, attempt, maxRetryDelay) {
let waitMs;
if (error.reset > 0) {
// Use the exact reset time from the header
waitMs = error.retryAfter;
}
else {
// Exponential backoff: 1s, 2s, 4s, 8s, etc.
waitMs = 1000 * Math.pow(2, attempt);
}
// Cap at maxRetryDelay
waitMs = Math.min(waitMs, maxRetryDelay);
// Add small jitter to prevent thundering herd (max 100ms)
const jitter = Math.random() * 100;
return waitMs + jitter;
}
/**
* Sleep for a specified number of milliseconds
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}