From 1a2abfae698ad767c1d3f4581ee8d92a5e992096 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 10:19:34 +0200 Subject: [PATCH 01/10] feat: Add HTTP 429 rate limiting support with automatic retry - Add LagoRateLimitError class for 429 responses - Parse x-ratelimit-* headers from responses - Implement automatic retry on 429 with configurable max retries - Support header-based reset timing and exponential backoff - Create fetch wrapper to intercept and handle rate limiting - Add LagoClientConfig with rateLimitRetry options - Include comprehensive tests for all rate limiting features - Add RATE_LIMITING.md documentation - Add rate_limiting_example.ts with usage patterns - Maintain backward compatibility with existing API - Do not modify generated OpenAPI code --- RATE_LIMITING.md | 394 ++++++++++++++++++++++++++++++ examples/rate_limiting_example.ts | 305 +++++++++++++++++++++++ mod.ts | 33 ++- rate_limit_error.ts | 27 ++ rate_limit_fetch.ts | 100 ++++++++ rate_limit_headers.ts | 33 +++ rate_limit_retry.ts | 99 ++++++++ tests/rate_limit.test.ts | 251 +++++++++++++++++++ 8 files changed, 1240 insertions(+), 2 deletions(-) create mode 100644 RATE_LIMITING.md create mode 100644 examples/rate_limiting_example.ts create mode 100644 rate_limit_error.ts create mode 100644 rate_limit_fetch.ts create mode 100644 rate_limit_headers.ts create mode 100644 rate_limit_retry.ts create mode 100644 tests/rate_limit.test.ts diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md new file mode 100644 index 0000000..d3a9ee9 --- /dev/null +++ b/RATE_LIMITING.md @@ -0,0 +1,394 @@ +# Rate Limiting Support + +The Lago JavaScript/TypeScript client now includes built-in support for handling HTTP 429 (Rate Limit) responses from the Lago API. + +## Overview + +When the Lago API rate limit is exceeded, it returns: +- HTTP 429 status code +- Response headers with rate limit information: + - `x-ratelimit-limit`: Maximum requests per window + - `x-ratelimit-remaining`: Remaining requests in current window + - `x-ratelimit-reset`: Seconds until the rate limit window resets + +The client can automatically retry requests after the rate limit window resets. + +## Quick Start + +### Enable Rate Limit Retry (Recommended) + +To enable automatic retry on rate limit errors: + +```typescript +import { Client } from "lago-javascript-client"; + +const client = Client("your-api-key", { + rateLimitRetry: { + maxRetries: 3, + retryOnRateLimit: true, + }, +}); + +// Requests will automatically retry on 429 responses +const customers = await client.customers.findCustomers(); +``` + +### Default Configuration + +If you enable `rateLimitRetry` without options, defaults are: +- `maxRetries`: 3 +- `retryOnRateLimit`: true + +```typescript +const client = Client("your-api-key", { + rateLimitRetry: {}, // Uses defaults +}); +``` + +### Disable Automatic Retry + +To throw errors on rate limit instead of retrying: + +```typescript +const client = Client("your-api-key", { + rateLimitRetry: { + retryOnRateLimit: false, + }, +}); + +// Will throw LagoRateLimitError on 429 +try { + await client.customers.findCustomers(); +} catch (error) { + if (error instanceof LagoRateLimitError) { + console.log(`Rate limited. Retry after ${error.reset} seconds`); + } +} +``` + +## API Reference + +### `LagoRateLimitError` + +Error class thrown when a rate limit is encountered. + +```typescript +class LagoRateLimitError extends Error { + limit: number; // Max requests per window + remaining: number; // Remaining requests + reset: number; // Seconds until window resets + retryAfter: number; // Milliseconds to wait before retry +} +``` + +**Example:** + +```typescript +try { + await client.customers.findCustomers(); +} catch (error) { + if (error instanceof LagoRateLimitError) { + console.log(`Limit: ${error.limit}`); + console.log(`Remaining: ${error.remaining}`); + console.log(`Reset in: ${error.reset}s`); + console.log(`Retry after: ${error.retryAfter}ms`); + } +} +``` + +### `parseRateLimitHeaders(headers: Headers): RateLimitHeaders` + +Manually parse rate limit headers from a response. + +```typescript +import { parseRateLimitHeaders } from "lago-javascript-client"; + +const response = await fetch("https://api.getlago.com/api/v1/customers"); +const rateLimits = parseRateLimitHeaders(response.headers); + +if (rateLimits.remaining === 0) { + console.log(`Rate limit will reset in ${rateLimits.reset}s`); +} +``` + +### `createRateLimitFetch(baseFetch, config): typeof fetch` + +Create a custom fetch function with rate limit retry logic. + +```typescript +import { createRateLimitFetch } from "lago-javascript-client"; + +const rateLimitFetch = createRateLimitFetch(globalThis.fetch, { + maxRetries: 5, + retryOnRateLimit: true, +}); + +const response = await rateLimitFetch("https://api.example.com/data"); +``` + +### `RateLimitRetryHandler` + +Advanced retry handler for custom use cases. + +```typescript +import { RateLimitRetryHandler } from "lago-javascript-client"; + +const handler = new RateLimitRetryHandler({ + maxRetries: 3, + retryOnRateLimit: true, +}); + +const result = await handler.retryWithBackoff(async () => { + return await myCustomApiCall(); +}); +``` + +## Retry Behavior + +### Retry Wait Time + +When a 429 response is received: + +1. **With `x-ratelimit-reset` header**: The client waits exactly that many seconds before retrying +2. **Without header**: The client uses exponential backoff starting at 1 second (1s, 2s, 4s, 8s, etc.) + +Both approaches include a small jitter (up to 100ms) to prevent the "thundering herd" problem. + +### Example Timeline + +``` +Request 1: sent → 429 (reset: 60s) → wait 60s +Request 2: sent → 200 OK +``` + +### Maximum Retries + +By default, the client will retry up to 3 times before giving up: + +``` +Attempt 1: 429 +Attempt 2: 429 +Attempt 3: 429 +Attempt 4: Throws LagoRateLimitError (no more retries) +``` + +Configure with `maxRetries` option: + +```typescript +rateLimitRetry: { + maxRetries: 5, // Try up to 5 times +} +``` + +## Configuration Options + +### `RateLimitFetchConfig` + +```typescript +interface RateLimitFetchConfig { + /** Maximum number of retries on 429 (default: 3) */ + maxRetries?: number; + + /** Whether to automatically retry on rate limit (default: true) */ + retryOnRateLimit?: boolean; +} +``` + +### `LagoClientConfig` + +```typescript +interface LagoClientConfig extends ApiConfig { + /** + * Rate limit retry configuration + */ + rateLimitRetry?: RateLimitFetchConfig; +} +``` + +## Examples + +### Example 1: Safe API Calls with Rate Limiting + +```typescript +import { Client, LagoRateLimitError } from "lago-javascript-client"; + +const client = Client("sk_live_xxxx", { + rateLimitRetry: { + maxRetries: 5, + retryOnRateLimit: true, + }, +}); + +async function getCustomer(id: string) { + try { + return await client.customers.findCustomer(id); + } catch (error) { + if (error instanceof LagoRateLimitError) { + // This shouldn't happen if retryOnRateLimit is true and maxRetries isn't exceeded + console.error("Rate limit exceeded after retries:", error.message); + } else { + console.error("API error:", error); + } + } +} +``` + +### Example 2: Batch Processing with Rate Limit Awareness + +```typescript +async function processCustomers(ids: string[]) { + const client = Client("sk_live_xxxx", { + rateLimitRetry: { + maxRetries: 3, + retryOnRateLimit: true, + }, + }); + + for (const id of ids) { + try { + const customer = await client.customers.findCustomer(id); + console.log(`Processed: ${customer.customer.external_id}`); + } catch (error) { + if (error instanceof LagoRateLimitError) { + console.log( + `Rate limited. Limit: ${error.limit}, Remaining: ${error.remaining}` + ); + // Client will auto-retry, but if maxRetries is exceeded: + console.log(`Next available request in ${error.reset}s`); + } + } + } +} +``` + +### Example 3: Custom Retry Logic + +```typescript +import { createRateLimitFetch } from "lago-javascript-client"; + +const customFetch = createRateLimitFetch(globalThis.fetch, { + maxRetries: 10, + retryOnRateLimit: true, +}); + +const client = Client("sk_live_xxxx", { + customFetch, // Use the custom fetch with rate limit handling +}); +``` + +### Example 4: Rate Limit Header Inspection + +```typescript +import { parseRateLimitHeaders } from "lago-javascript-client"; + +const response = await fetch("https://api.getlago.com/api/v1/customers"); +const limits = parseRateLimitHeaders(response.headers); + +console.log(`Limit: ${limits.limit} requests per window`); +console.log(`Remaining: ${limits.remaining}`); +console.log(`Reset in: ${limits.reset}s`); + +if (limits.remaining === 0) { + console.warn("Rate limit exhausted!"); +} +``` + +## Implementation Details + +### Architecture + +The rate limiting implementation consists of: + +1. **`LagoRateLimitError`**: Custom error class for 429 responses +2. **`rate_limit_headers.ts`**: Header parsing utilities +3. **`rate_limit_fetch.ts`**: Fetch wrapper with automatic retry logic +4. **`rate_limit_retry.ts`**: Advanced retry handler (extensible for custom use) +5. **Integration in `mod.ts`**: Seamless client integration + +### How It Works + +1. When the client makes a request, it uses the rate-limit-aware fetch +2. If a 429 response is received: + - Rate limit headers are parsed + - A `LagoRateLimitError` is created with retry timing information + - If `retryOnRateLimit` is true, the client sleeps for the appropriate duration and retries + - If max retries are exceeded, the error is thrown +3. All other responses (including errors) are passed through unchanged + +### No Generated Code Modification + +The implementation: +- Does NOT modify the generated OpenAPI client code +- Uses a fetch wrapper to intercept HTTP responses +- Is completely backward compatible +- Works with any ApiConfig options +- Respects existing customFetch implementations + +## Troubleshooting + +### Requests Still Timing Out + +If requests are taking a long time, they might be retrying due to rate limiting: + +```typescript +rateLimitRetry: { + maxRetries: 1, // Reduce retries for faster failure + retryOnRateLimit: true, +} +``` + +### Want to Handle Rate Limits Manually + +Disable auto-retry and handle errors yourself: + +```typescript +const client = Client("sk_live_xxxx", { + rateLimitRetry: { + retryOnRateLimit: false, + }, +}); + +try { + await client.customers.findCustomer(id); +} catch (error) { + if (error instanceof LagoRateLimitError) { + // Handle rate limit with your own logic + } +} +``` + +### Rate Limit Headers Not Available + +If headers are not being parsed, check that: +1. The server is returning the rate limit headers +2. Your proxy/middleware isn't stripping headers +3. The header names match exactly (lowercase): `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` + +## Testing + +Rate limiting can be tested using the mock fetch from the test utilities: + +```typescript +import { mf } from "../dev_deps.ts"; +import { createRateLimitFetch } from "../mod.ts"; + +const { fetch, mock } = mf.sandbox(); + +mock("https://example.com/api", () => { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "60", + }, + }); +}); + +const rateLimitFetch = createRateLimitFetch(fetch, { + retryOnRateLimit: true, + maxRetries: 3, +}); + +const response = await rateLimitFetch("https://example.com/api"); +``` diff --git a/examples/rate_limiting_example.ts b/examples/rate_limiting_example.ts new file mode 100644 index 0000000..4b7626a --- /dev/null +++ b/examples/rate_limiting_example.ts @@ -0,0 +1,305 @@ +/** + * Example: Using Rate Limiting with Lago Client + * + * This example demonstrates how to use the rate limiting features + * of the Lago JavaScript/TypeScript client. + */ + +import { Client, LagoRateLimitError, parseRateLimitHeaders } from "../mod.ts"; + +/** + * Example 1: Basic usage with automatic retry + */ +async function example1_BasicRateLimitRetry() { + console.log("Example 1: Basic rate limit retry\n"); + + // Initialize client with rate limit retry enabled + const client = Client("sk_live_xxx_your_api_key", { + rateLimitRetry: { + maxRetries: 3, + retryOnRateLimit: true, + }, + }); + + try { + // If this request gets rate limited, it will automatically retry + // after waiting for the reset time specified in the response headers + const customers = await client.customers.findCustomers(); + console.log(`Successfully fetched customers`); + } catch (error) { + if (error instanceof LagoRateLimitError) { + console.error(`Rate limit error: ${error.message}`); + console.error(`Limit: ${error.limit}, Remaining: ${error.remaining}`); + console.error(`Reset in: ${error.reset}s`); + } else { + console.error("Other error:", error); + } + } +} + +/** + * Example 2: Manual rate limit handling + */ +async function example2_ManualRateLimitHandling() { + console.log("\nExample 2: Manual rate limit handling\n"); + + // Disable automatic retry to handle rate limits manually + const client = Client("sk_live_xxx_your_api_key", { + rateLimitRetry: { + retryOnRateLimit: false, // Will throw instead of retry + }, + }); + + try { + const customers = await client.customers.findCustomers(); + console.log("Successfully fetched customers"); + } catch (error) { + if (error instanceof LagoRateLimitError) { + // Handle the rate limit error with custom logic + console.warn( + `Rate limited. Please retry after ${error.reset} seconds.` + ); + + // You could implement custom retry logic here + await sleep(error.reset * 1000); + // Retry the request... + } else { + console.error("Other error:", error); + } + } +} + +/** + * Example 3: Batch processing with rate limit awareness + */ +async function example3_BatchProcessing() { + console.log("\nExample 3: Batch processing\n"); + + const client = Client("sk_live_xxx_your_api_key", { + rateLimitRetry: { + maxRetries: 5, + retryOnRateLimit: true, + }, + }); + + const customerIds = [ + "cust_001", + "cust_002", + "cust_003", + "cust_004", + "cust_005", + ]; + + for (const id of customerIds) { + try { + const customer = await client.customers.findCustomer(id); + console.log( + `Processed customer: ${customer.customer.external_id}` + ); + } catch (error) { + if (error instanceof LagoRateLimitError) { + console.error( + `Rate limited. Remaining: ${error.remaining}/${error.limit}` + ); + console.error( + `Next request possible in ${error.reset}s` + ); + + // Optional: implement exponential backoff or jitter + // for distributed processing + } else { + console.error(`Error processing customer ${id}:`, error); + } + } + } +} + +/** + * Example 4: Inspecting rate limit headers directly + */ +async function example4_InspectRateLimitHeaders() { + console.log("\nExample 4: Inspect rate limit headers\n"); + + // Create a client without automatic retry + const client = Client("sk_live_xxx_your_api_key", { + rateLimitRetry: { + retryOnRateLimit: false, + }, + }); + + try { + // Make a request - note: this is pseudo-code since we can't + // directly access Response objects from the Lago API + // In real usage, you'd need to wrap the client methods + + console.log("This example would typically wrap the fetch layer"); + console.log("to inspect response headers directly"); + } catch (error) { + console.error("Error:", error); + } +} + +/** + * Example 5: Advanced configuration with custom retry limits + */ +async function example5_AdvancedConfiguration() { + console.log("\nExample 5: Advanced configuration\n"); + + // Different configs for different use cases + const configs = { + // Conservative: Few retries for time-sensitive operations + conservative: { + maxRetries: 1, + retryOnRateLimit: true, + }, + + // Moderate: Default retry behavior + moderate: { + maxRetries: 3, + retryOnRateLimit: true, + }, + + // Aggressive: Many retries for critical operations + aggressive: { + maxRetries: 10, + retryOnRateLimit: true, + }, + + // Manual: No automatic retry + manual: { + retryOnRateLimit: false, + }, + }; + + // Use conservative approach for time-sensitive operations + const timeSensitiveClient = Client( + "sk_live_xxx_your_api_key", + { + rateLimitRetry: configs.conservative, + } + ); + + // Use aggressive approach for critical batch operations + const batchClient = Client( + "sk_live_xxx_your_api_key", + { + rateLimitRetry: configs.aggressive, + } + ); + + console.log("Configured clients with different retry strategies"); + console.log("Conservative (1 retry): time-sensitive operations"); + console.log("Aggressive (10 retries): batch operations"); +} + +/** + * Example 6: Error handling best practices + */ +async function example6_ErrorHandlingBestPractices() { + console.log("\nExample 6: Error handling best practices\n"); + + const client = Client("sk_live_xxx_your_api_key", { + rateLimitRetry: { + maxRetries: 3, + retryOnRateLimit: true, + }, + }); + + async function makeApiRequest() { + try { + return await client.customers.findCustomers(); + } catch (error) { + // Handle rate limit errors + if (error instanceof LagoRateLimitError) { + console.error("Rate limit exceeded:"); + console.error(` Limit: ${error.limit} requests per window`); + console.error(` Remaining: ${error.remaining}`); + console.error(` Reset in: ${error.reset} seconds`); + + // Option 1: Implement exponential backoff + const backoffMs = Math.pow(2, 3) * 1000; // 8 seconds + console.log(`Waiting ${backoffMs}ms before retry...`); + await sleep(backoffMs); + + // Option 2: Notify monitoring/alerting system + console.log("Alert: Rate limit exhausted - check API quota"); + + // Option 3: Gracefully degrade service + return null; // Return cached data or default value + } + + // Handle other API errors + if (error instanceof Error && error.message.includes("Unauthorized")) { + console.error("Authentication failed - check your API key"); + return null; + } + + // Handle network errors + if (error instanceof TypeError) { + console.error("Network error - check connectivity"); + return null; + } + + // Unknown error + throw error; + } + } + + const result = await makeApiRequest(); + console.log("Request completed with robust error handling"); +} + +/** + * Helper function: sleep + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Run all examples + */ +async function runExamples() { + console.log("========================================"); + console.log("Lago Client Rate Limiting Examples"); + console.log("========================================\n"); + + try { + // Note: These examples are pseudo-code and would require + // actual API calls to work properly + + console.log("Example code loaded successfully!"); + console.log("\nTo use these examples:"); + console.log( + "1. Replace 'sk_live_xxx_your_api_key' with your actual API key" + ); + console.log( + "2. Uncomment the example you want to run" + ); + console.log("3. Run the script\n"); + + // Uncomment to run examples: + // await example1_BasicRateLimitRetry(); + // await example2_ManualRateLimitHandling(); + // await example3_BatchProcessing(); + // await example4_InspectRateLimitHeaders(); + // await example5_AdvancedConfiguration(); + // await example6_ErrorHandlingBestPractices(); + } catch (error) { + console.error("Error running examples:", error); + } +} + +// Only run examples if this file is executed directly +if (import.meta.main) { + runExamples(); +} + +export { + example1_BasicRateLimitRetry, + example2_ManualRateLimitHandling, + example3_BatchProcessing, + example4_InspectRateLimitHeaders, + example5_AdvancedConfiguration, + example6_ErrorHandlingBestPractices, +}; diff --git a/mod.ts b/mod.ts index 7eec3d7..5a6c77e 100644 --- a/mod.ts +++ b/mod.ts @@ -1,7 +1,26 @@ // deno-lint-ignore-file no-explicit-any import { Api, ApiConfig, HttpResponse } from "./openapi/client.ts"; +import { createRateLimitFetch } from "./rate_limit_fetch.ts"; +import type { RateLimitFetchConfig } from "./rate_limit_fetch.ts"; + +export interface LagoClientConfig extends ApiConfig { + /** + * Rate limit retry configuration + */ + rateLimitRetry?: RateLimitFetchConfig; +} + +export const Client = (apiKey: string, apiConfig?: LagoClientConfig) => { + const { rateLimitRetry, ...restConfig } = apiConfig ?? {}; + + // Create rate-limit-aware fetch if configured + const customFetch = rateLimitRetry + ? createRateLimitFetch( + (restConfig?.customFetch ?? globalThis.fetch) as typeof fetch, + rateLimitRetry, + ) + : restConfig?.customFetch; -export const Client = (apiKey: string, apiConfig?: ApiConfig) => { const api = new Api({ securityWorker: (apiKey) => apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}, @@ -9,7 +28,8 @@ export const Client = (apiKey: string, apiConfig?: ApiConfig) => { baseApiParams: { redirect: "follow", }, - ...apiConfig, + ...restConfig, + ...(customFetch && { customFetch }), }); api.setSecurityData(apiKey); return api; @@ -42,4 +62,13 @@ export async function getLagoError(error: any) { throw new Error(error); } +// Rate limit exports +export { LagoRateLimitError } from "./rate_limit_error.ts"; +export { parseRateLimitHeaders, type RateLimitHeaders } from "./rate_limit_headers.ts"; +export { + RateLimitRetryHandler, + type RateLimitRetryConfig, +} from "./rate_limit_retry.ts"; +export { createRateLimitFetch, type RateLimitFetchConfig } from "./rate_limit_fetch.ts"; + export * from "./openapi/client.ts"; diff --git a/rate_limit_error.ts b/rate_limit_error.ts new file mode 100644 index 0000000..28e7931 --- /dev/null +++ b/rate_limit_error.ts @@ -0,0 +1,27 @@ +/** + * Error class for rate limit (HTTP 429) responses + */ +export class LagoRateLimitError extends Error { + public readonly limit: number; + public readonly remaining: number; + public readonly reset: number; // seconds until window resets + public readonly retryAfter: number; // milliseconds to wait before retrying + + constructor( + limit: number, + remaining: number, + reset: number, + ) { + super( + `Rate limit exceeded. Limit: ${limit}, Remaining: ${remaining}, Reset in: ${reset}s`, + ); + this.name = "LagoRateLimitError"; + this.limit = limit; + this.remaining = remaining; + this.reset = reset; + this.retryAfter = reset * 1000; // Convert seconds to milliseconds + + // Maintain proper prototype chain for instanceof checks + Object.setPrototypeOf(this, LagoRateLimitError.prototype); + } +} diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts new file mode 100644 index 0000000..d0e3369 --- /dev/null +++ b/rate_limit_fetch.ts @@ -0,0 +1,100 @@ +import { LagoRateLimitError } from "./rate_limit_error.ts"; +import { + parseRateLimitHeaders, + type RateLimitHeaders, +} from "./rate_limit_headers.ts"; + +/** + * Configuration for rate limit fetch behavior + */ +export interface RateLimitFetchConfig { + /** Maximum number of retries on 429 (default: 3) */ + maxRetries?: number; + /** Whether to automatically retry on rate limit (default: true) */ + retryOnRateLimit?: boolean; +} + +/** + * 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: typeof fetch, + config: RateLimitFetchConfig = {}, +): typeof fetch { + const maxRetries = config.maxRetries ?? 3; + const retryOnRateLimit = config.retryOnRateLimit ?? true; + + return async function rateLimitFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + let lastError: unknown; + + 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 ?? 60; + + 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); + await sleep(waitMs); + continue; // Retry + } + + // Success or non-rate-limit error - return the response + 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; + }; +} + +/** + * Calculate wait time before retry + * Uses the exact reset time from headers if available, otherwise exponential backoff + */ +function getWaitTime(error: LagoRateLimitError, attempt: number): number { + // Use the exact reset time from the header + let waitMs = error.retryAfter; + + // 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: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/rate_limit_headers.ts b/rate_limit_headers.ts new file mode 100644 index 0000000..b1de8d3 --- /dev/null +++ b/rate_limit_headers.ts @@ -0,0 +1,33 @@ +/** + * Parses rate limit headers from HTTP responses + */ +export interface RateLimitHeaders { + limit: number | null; + remaining: number | null; + reset: number | null; +} + +/** + * Extract rate limit information from response headers + */ +export function parseRateLimitHeaders(headers: Headers): RateLimitHeaders { + return { + limit: parseHeaderAsNumber(headers, "x-ratelimit-limit"), + remaining: parseHeaderAsNumber(headers, "x-ratelimit-remaining"), + reset: parseHeaderAsNumber(headers, "x-ratelimit-reset"), + }; +} + +/** + * Helper to parse a header value as a number + */ +function parseHeaderAsNumber( + headers: Headers, + headerName: string, +): number | null { + const value = headers.get(headerName); + if (value === null) return null; + + const num = parseInt(value, 10); + return isNaN(num) ? null : num; +} diff --git a/rate_limit_retry.ts b/rate_limit_retry.ts new file mode 100644 index 0000000..38ecaa8 --- /dev/null +++ b/rate_limit_retry.ts @@ -0,0 +1,99 @@ +import { LagoRateLimitError } from "./rate_limit_error.ts"; +import { parseRateLimitHeaders } from "./rate_limit_headers.ts"; + +/** + * Configuration for rate limit retry behavior + */ +export interface RateLimitRetryConfig { + /** Maximum number of retries on 429 (default: 3) */ + maxRetries?: number; + /** Whether to automatically retry on rate limit (default: true) */ + retryOnRateLimit?: boolean; +} + +/** + * Retry handler for rate-limited requests + * Handles 429 responses with automatic retry based on rate limit headers + */ +export class RateLimitRetryHandler { + private maxRetries: number; + private retryOnRateLimit: boolean; + + constructor(config: RateLimitRetryConfig = {}) { + this.maxRetries = config.maxRetries ?? 3; + this.retryOnRateLimit = config.retryOnRateLimit ?? true; + } + + /** + * Handle a response that may have rate limiting + * Throws LagoRateLimitError on 429, or rethrows if retryOnRateLimit is false + */ + async handleResponse(response: Response): Promise { + if (response.status === 429) { + const headers = parseRateLimitHeaders(response.headers); + + // Parse rate limit headers to create error + const limit = headers.limit ?? -1; + const remaining = headers.remaining ?? 0; + const reset = headers.reset ?? 60; // Default to 60s if not provided + + if (!this.retryOnRateLimit) { + throw new LagoRateLimitError(limit, remaining, reset); + } + + throw new LagoRateLimitError(limit, remaining, reset); + } + + return response; + } + + /** + * Sleep for a specified number of milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Retry a request function with exponential backoff on rate limit + */ + async retryWithBackoff( + requestFn: () => Promise, + isRateLimitError: (error: unknown) => boolean = (e) => + e instanceof LagoRateLimitError, + ): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + return await requestFn(); + } catch (error) { + lastError = error; + + if (!isRateLimitError(error)) { + throw error; // Not a rate limit error, rethrow immediately + } + + if (attempt === this.maxRetries) { + throw error; // Max retries reached + } + + // Calculate wait time + let waitMs: number; + if (error instanceof LagoRateLimitError) { + // 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); + } + + // Add small jitter to prevent thundering herd + const jitter = Math.random() * 100; + await this.sleep(waitMs + jitter); + } + } + + throw lastError; + } +} diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts new file mode 100644 index 0000000..782feec --- /dev/null +++ b/tests/rate_limit.test.ts @@ -0,0 +1,251 @@ +import { assertEquals, assertExists } from "../dev_deps.ts"; +import { mf } from "../dev_deps.ts"; +import { + Client, + LagoRateLimitError, + parseRateLimitHeaders, + createRateLimitFetch, +} from "../mod.ts"; + +Deno.test("LagoRateLimitError contains rate limit information", () => { + const error = new LagoRateLimitError(100, 0, 60); + + assertEquals(error.limit, 100); + assertEquals(error.remaining, 0); + assertEquals(error.reset, 60); + assertEquals(error.retryAfter, 60000); // 60 seconds in milliseconds + assertEquals(error.name, "LagoRateLimitError"); +}); + +Deno.test("parseRateLimitHeaders extracts headers correctly", () => { + const headers = new Headers({ + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "45", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, 100); + assertEquals(result.remaining, 10); + assertEquals(result.reset, 45); +}); + +Deno.test("parseRateLimitHeaders handles missing headers", () => { + const headers = new Headers({ + "content-type": "application/json", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, null); + assertEquals(result.remaining, null); + assertEquals(result.reset, null); +}); + +Deno.test("parseRateLimitHeaders handles invalid header values", () => { + const headers = new Headers({ + "x-ratelimit-limit": "not-a-number", + "x-ratelimit-remaining": "abc", + "x-ratelimit-reset": "", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, null); + assertEquals(result.remaining, null); + assertEquals(result.reset, null); +}); + +Deno.test("createRateLimitFetch throws LagoRateLimitError on 429 without retry", async () => { + const { fetch, mock } = mf.sandbox(); + + mock("https://example.com/api", () => { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "60", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(fetch, { + retryOnRateLimit: false, + }); + + try { + await rateLimitFetch("https://example.com/api"); + assertEquals(true, false, "Should have thrown LagoRateLimitError"); + } catch (error) { + if (error instanceof LagoRateLimitError) { + assertEquals(error.limit, 100); + assertEquals(error.remaining, 0); + assertEquals(error.reset, 60); + } else { + throw error; + } + } +}); + +Deno.test("createRateLimitFetch retries on 429 with correct wait time", async () => { + const { fetch, mock } = mf.sandbox(); + let callCount = 0; + + mock("https://example.com/api", () => { + callCount++; + if (callCount === 1) { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1", // 1 second wait + }, + }); + } + return new Response('{"success": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "99", + "x-ratelimit-reset": "3600", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(fetch, { + retryOnRateLimit: true, + maxRetries: 3, + }); + + const startTime = Date.now(); + const response = await rateLimitFetch("https://example.com/api"); + const elapsed = Date.now() - startTime; + + assertEquals(response.status, 200); + assertEquals(callCount, 2); + // Should have waited approximately 1 second (1000ms) + jitter + assertEquals(elapsed >= 1000, true); +}); + +Deno.test("createRateLimitFetch respects maxRetries", async () => { + const { fetch, mock } = mf.sandbox(); + let callCount = 0; + + mock("https://example.com/api", () => { + callCount++; + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(fetch, { + retryOnRateLimit: true, + maxRetries: 2, + }); + + try { + await rateLimitFetch("https://example.com/api"); + assertEquals(true, false, "Should have thrown LagoRateLimitError"); + } catch (error) { + if (error instanceof LagoRateLimitError) { + // Called: attempt 0 (fails), attempt 1 (fails), attempt 2 (fails and gives up) + assertEquals(callCount, 3); + } else { + throw error; + } + } +}); + +Deno.test("createRateLimitFetch passes through non-429 errors", async () => { + const { fetch, mock } = mf.sandbox(); + + mock("https://example.com/api", () => { + return new Response("Server error", { + status: 500, + }); + }); + + const rateLimitFetch = createRateLimitFetch(fetch, { + retryOnRateLimit: true, + }); + + const response = await rateLimitFetch("https://example.com/api"); + assertEquals(response.status, 500); +}); + +Deno.test("Client with rateLimitRetry config uses rate limit fetch", async () => { + const { fetch, mock } = mf.sandbox(); + let callCount = 0; + + mock("https://api.example.com/api/v1/customers", () => { + callCount++; + if (callCount === 1) { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1", + }, + }); + } + return new Response('{"customers": []}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "99", + "x-ratelimit-reset": "3600", + }, + }); + }); + + const client = Client("test-api-key", { + baseUrl: "https://api.example.com", + customFetch: fetch, + rateLimitRetry: { + retryOnRateLimit: true, + maxRetries: 3, + }, + }); + + assertExists(client); + assertEquals(callCount, 0); // Not called yet +}); + +Deno.test("createRateLimitFetch includes rate limit headers in success response", async () => { + const { fetch, mock } = mf.sandbox(); + + mock("https://example.com/api", () => { + return new Response('{"data": "test"}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "50", + "x-ratelimit-reset": "3600", + "content-type": "application/json", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(fetch); + const response = await rateLimitFetch("https://example.com/api"); + + assertEquals(response.status, 200); + assertEquals(response.headers.get("x-ratelimit-limit"), "100"); + assertEquals(response.headers.get("x-ratelimit-remaining"), "50"); + assertEquals(response.headers.get("x-ratelimit-reset"), "3600"); +}); + +Deno.test("LagoRateLimitError is instanceof Error", () => { + const error = new LagoRateLimitError(100, 0, 60); + assertEquals(error instanceof Error, true); + assertEquals(error instanceof LagoRateLimitError, true); +}); From 6630f7a96974ec8c7afa22a96c6b8dbb19c371ed Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 10:55:33 +0200 Subject: [PATCH 02/10] fix: correct rate limit test setup --- tests/rate_limit.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts index 782feec..a0314a8 100644 --- a/tests/rate_limit.test.ts +++ b/tests/rate_limit.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertExists } from "../dev_deps.ts"; +import { assertEquals } from "../dev_deps.ts"; import { mf } from "../dev_deps.ts"; import { Client, @@ -216,7 +216,8 @@ Deno.test("Client with rateLimitRetry config uses rate limit fetch", async () => }, }); - assertExists(client); + // Verify client was created + assertEquals(typeof client, "object"); assertEquals(callCount, 0); // Not called yet }); From 34dd9d78d78275e79a12745beab450ba0c8b6341 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 12:38:27 +0200 Subject: [PATCH 03/10] fix: replace broken mock_fetch with simple fetch mock mock_fetch library's URLPattern is incompatible with current Deno. Replace with a lightweight createMockFetch helper. Co-Authored-By: Claude Opus 4.6 --- tests/rate_limit.test.ts | 66 +++++++++++++--------------------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts index a0314a8..5288a1d 100644 --- a/tests/rate_limit.test.ts +++ b/tests/rate_limit.test.ts @@ -1,5 +1,4 @@ import { assertEquals } from "../dev_deps.ts"; -import { mf } from "../dev_deps.ts"; import { Client, LagoRateLimitError, @@ -7,6 +6,13 @@ import { createRateLimitFetch, } from "../mod.ts"; +// Simple fetch mock helper (replaces broken mock_fetch library) +function createMockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + return Promise.resolve(handler(input, init)); + }; +} + Deno.test("LagoRateLimitError contains rate limit information", () => { const error = new LagoRateLimitError(100, 0, 60); @@ -58,9 +64,7 @@ Deno.test("parseRateLimitHeaders handles invalid header values", () => { }); Deno.test("createRateLimitFetch throws LagoRateLimitError on 429 without retry", async () => { - const { fetch, mock } = mf.sandbox(); - - mock("https://example.com/api", () => { + const mockFetch = createMockFetch(() => { return new Response("Rate limited", { status: 429, headers: { @@ -71,7 +75,7 @@ Deno.test("createRateLimitFetch throws LagoRateLimitError on 429 without retry", }); }); - const rateLimitFetch = createRateLimitFetch(fetch, { + const rateLimitFetch = createRateLimitFetch(mockFetch, { retryOnRateLimit: false, }); @@ -90,10 +94,9 @@ Deno.test("createRateLimitFetch throws LagoRateLimitError on 429 without retry", }); Deno.test("createRateLimitFetch retries on 429 with correct wait time", async () => { - const { fetch, mock } = mf.sandbox(); let callCount = 0; - mock("https://example.com/api", () => { + const mockFetch = createMockFetch(() => { callCount++; if (callCount === 1) { return new Response("Rate limited", { @@ -115,7 +118,7 @@ Deno.test("createRateLimitFetch retries on 429 with correct wait time", async () }); }); - const rateLimitFetch = createRateLimitFetch(fetch, { + const rateLimitFetch = createRateLimitFetch(mockFetch, { retryOnRateLimit: true, maxRetries: 3, }); @@ -131,10 +134,9 @@ Deno.test("createRateLimitFetch retries on 429 with correct wait time", async () }); Deno.test("createRateLimitFetch respects maxRetries", async () => { - const { fetch, mock } = mf.sandbox(); let callCount = 0; - mock("https://example.com/api", () => { + const mockFetch = createMockFetch(() => { callCount++; return new Response("Rate limited", { status: 429, @@ -146,7 +148,7 @@ Deno.test("createRateLimitFetch respects maxRetries", async () => { }); }); - const rateLimitFetch = createRateLimitFetch(fetch, { + const rateLimitFetch = createRateLimitFetch(mockFetch, { retryOnRateLimit: true, maxRetries: 2, }); @@ -165,15 +167,13 @@ Deno.test("createRateLimitFetch respects maxRetries", async () => { }); Deno.test("createRateLimitFetch passes through non-429 errors", async () => { - const { fetch, mock } = mf.sandbox(); - - mock("https://example.com/api", () => { + const mockFetch = createMockFetch(() => { return new Response("Server error", { status: 500, }); }); - const rateLimitFetch = createRateLimitFetch(fetch, { + const rateLimitFetch = createRateLimitFetch(mockFetch, { retryOnRateLimit: true, }); @@ -181,35 +181,14 @@ Deno.test("createRateLimitFetch passes through non-429 errors", async () => { assertEquals(response.status, 500); }); -Deno.test("Client with rateLimitRetry config uses rate limit fetch", async () => { - const { fetch, mock } = mf.sandbox(); - let callCount = 0; - - mock("https://api.example.com/api/v1/customers", () => { - callCount++; - if (callCount === 1) { - return new Response("Rate limited", { - status: 429, - headers: { - "x-ratelimit-limit": "100", - "x-ratelimit-remaining": "0", - "x-ratelimit-reset": "1", - }, - }); - } - return new Response('{"customers": []}', { - status: 200, - headers: { - "x-ratelimit-limit": "100", - "x-ratelimit-remaining": "99", - "x-ratelimit-reset": "3600", - }, - }); +Deno.test("Client with rateLimitRetry config uses rate limit fetch", () => { + const mockFetch = createMockFetch(() => { + return new Response('{"customers": []}', { status: 200 }); }); const client = Client("test-api-key", { baseUrl: "https://api.example.com", - customFetch: fetch, + customFetch: mockFetch, rateLimitRetry: { retryOnRateLimit: true, maxRetries: 3, @@ -218,13 +197,10 @@ Deno.test("Client with rateLimitRetry config uses rate limit fetch", async () => // Verify client was created assertEquals(typeof client, "object"); - assertEquals(callCount, 0); // Not called yet }); Deno.test("createRateLimitFetch includes rate limit headers in success response", async () => { - const { fetch, mock } = mf.sandbox(); - - mock("https://example.com/api", () => { + const mockFetch = createMockFetch(() => { return new Response('{"data": "test"}', { status: 200, headers: { @@ -236,7 +212,7 @@ Deno.test("createRateLimitFetch includes rate limit headers in success response" }); }); - const rateLimitFetch = createRateLimitFetch(fetch); + const rateLimitFetch = createRateLimitFetch(mockFetch); const response = await rateLimitFetch("https://example.com/api"); assertEquals(response.status, 200); From 151788144c421f20338ddefe775751481b040cb5 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 12:59:33 +0200 Subject: [PATCH 04/10] chore: gitignore ARCHITECTURE.md Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3503a4d..6d0a14c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ web_modules/ npm/ openapi/ +ARCHITECTURE.md From 7f53c0be111c1ac7aa55cb4a7e7bfe2c3bbb293a Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 12:59:56 +0200 Subject: [PATCH 05/10] chore: update deno.lock Co-Authored-By: Claude Opus 4.6 --- deno.lock | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/deno.lock b/deno.lock index c482e5f..df0be00 100644 --- a/deno.lock +++ b/deno.lock @@ -1,5 +1,10 @@ { - "version": "2", + "version": "5", + "redirects": { + "https://crux.land/api/get/2KNRVU": "https://crux.land/api/get/2KNRVU.ts", + "https://crux.land/api/get/router@0.0.5": "https://crux.land/api/get/2KNRVU", + "https://crux.land/router@0.0.5": "https://crux.land/api/get/router@0.0.5" + }, "remote": { "https://crux.land/api/get/2KNRVU.ts": "6a77d55844aba78d01520c5ff0b2f0af7f24cc1716a0de8b3bb6bd918c47b5ba", "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", From 7c9adec8cae3940bbd50b927295435c04e48d80c Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Fri, 10 Apr 2026 16:18:54 +0200 Subject: [PATCH 06/10] fix: implement real exponential backoff, remove dead code and generated docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix getWaitTime to actually fall back to exponential backoff (1s, 2s, 4s...) when x-ratelimit-reset header is missing. Previously always used error.retryAfter which defaulted to 60s, meaning every retry without a header waited a full minute. - Change missing-header default from 60 to -1 so the backoff path is triggered correctly. - Remove RateLimitRetryHandler class (rate_limit_retry.ts) — both branches of handleResponse() threw unconditionally, making the retryOnRateLimit flag a no-op. The class was never used by createRateLimitFetch. Remove its export from mod.ts. - Remove RATE_LIMITING.md (394 lines) and examples/rate_limiting_example.ts (305 lines of commented-out code). - Add test verifying exponential backoff triggers ~1s wait (not 60s) when reset header is absent. Co-Authored-By: Claude Opus 4.6 (1M context) --- RATE_LIMITING.md | 394 ------------------------------ examples/rate_limiting_example.ts | 305 ----------------------- mod.ts | 4 - rate_limit_fetch.ts | 13 +- rate_limit_retry.ts | 99 -------- tests/rate_limit.test.ts | 34 +++ 6 files changed, 44 insertions(+), 805 deletions(-) delete mode 100644 RATE_LIMITING.md delete mode 100644 examples/rate_limiting_example.ts delete mode 100644 rate_limit_retry.ts diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md deleted file mode 100644 index d3a9ee9..0000000 --- a/RATE_LIMITING.md +++ /dev/null @@ -1,394 +0,0 @@ -# Rate Limiting Support - -The Lago JavaScript/TypeScript client now includes built-in support for handling HTTP 429 (Rate Limit) responses from the Lago API. - -## Overview - -When the Lago API rate limit is exceeded, it returns: -- HTTP 429 status code -- Response headers with rate limit information: - - `x-ratelimit-limit`: Maximum requests per window - - `x-ratelimit-remaining`: Remaining requests in current window - - `x-ratelimit-reset`: Seconds until the rate limit window resets - -The client can automatically retry requests after the rate limit window resets. - -## Quick Start - -### Enable Rate Limit Retry (Recommended) - -To enable automatic retry on rate limit errors: - -```typescript -import { Client } from "lago-javascript-client"; - -const client = Client("your-api-key", { - rateLimitRetry: { - maxRetries: 3, - retryOnRateLimit: true, - }, -}); - -// Requests will automatically retry on 429 responses -const customers = await client.customers.findCustomers(); -``` - -### Default Configuration - -If you enable `rateLimitRetry` without options, defaults are: -- `maxRetries`: 3 -- `retryOnRateLimit`: true - -```typescript -const client = Client("your-api-key", { - rateLimitRetry: {}, // Uses defaults -}); -``` - -### Disable Automatic Retry - -To throw errors on rate limit instead of retrying: - -```typescript -const client = Client("your-api-key", { - rateLimitRetry: { - retryOnRateLimit: false, - }, -}); - -// Will throw LagoRateLimitError on 429 -try { - await client.customers.findCustomers(); -} catch (error) { - if (error instanceof LagoRateLimitError) { - console.log(`Rate limited. Retry after ${error.reset} seconds`); - } -} -``` - -## API Reference - -### `LagoRateLimitError` - -Error class thrown when a rate limit is encountered. - -```typescript -class LagoRateLimitError extends Error { - limit: number; // Max requests per window - remaining: number; // Remaining requests - reset: number; // Seconds until window resets - retryAfter: number; // Milliseconds to wait before retry -} -``` - -**Example:** - -```typescript -try { - await client.customers.findCustomers(); -} catch (error) { - if (error instanceof LagoRateLimitError) { - console.log(`Limit: ${error.limit}`); - console.log(`Remaining: ${error.remaining}`); - console.log(`Reset in: ${error.reset}s`); - console.log(`Retry after: ${error.retryAfter}ms`); - } -} -``` - -### `parseRateLimitHeaders(headers: Headers): RateLimitHeaders` - -Manually parse rate limit headers from a response. - -```typescript -import { parseRateLimitHeaders } from "lago-javascript-client"; - -const response = await fetch("https://api.getlago.com/api/v1/customers"); -const rateLimits = parseRateLimitHeaders(response.headers); - -if (rateLimits.remaining === 0) { - console.log(`Rate limit will reset in ${rateLimits.reset}s`); -} -``` - -### `createRateLimitFetch(baseFetch, config): typeof fetch` - -Create a custom fetch function with rate limit retry logic. - -```typescript -import { createRateLimitFetch } from "lago-javascript-client"; - -const rateLimitFetch = createRateLimitFetch(globalThis.fetch, { - maxRetries: 5, - retryOnRateLimit: true, -}); - -const response = await rateLimitFetch("https://api.example.com/data"); -``` - -### `RateLimitRetryHandler` - -Advanced retry handler for custom use cases. - -```typescript -import { RateLimitRetryHandler } from "lago-javascript-client"; - -const handler = new RateLimitRetryHandler({ - maxRetries: 3, - retryOnRateLimit: true, -}); - -const result = await handler.retryWithBackoff(async () => { - return await myCustomApiCall(); -}); -``` - -## Retry Behavior - -### Retry Wait Time - -When a 429 response is received: - -1. **With `x-ratelimit-reset` header**: The client waits exactly that many seconds before retrying -2. **Without header**: The client uses exponential backoff starting at 1 second (1s, 2s, 4s, 8s, etc.) - -Both approaches include a small jitter (up to 100ms) to prevent the "thundering herd" problem. - -### Example Timeline - -``` -Request 1: sent → 429 (reset: 60s) → wait 60s -Request 2: sent → 200 OK -``` - -### Maximum Retries - -By default, the client will retry up to 3 times before giving up: - -``` -Attempt 1: 429 -Attempt 2: 429 -Attempt 3: 429 -Attempt 4: Throws LagoRateLimitError (no more retries) -``` - -Configure with `maxRetries` option: - -```typescript -rateLimitRetry: { - maxRetries: 5, // Try up to 5 times -} -``` - -## Configuration Options - -### `RateLimitFetchConfig` - -```typescript -interface RateLimitFetchConfig { - /** Maximum number of retries on 429 (default: 3) */ - maxRetries?: number; - - /** Whether to automatically retry on rate limit (default: true) */ - retryOnRateLimit?: boolean; -} -``` - -### `LagoClientConfig` - -```typescript -interface LagoClientConfig extends ApiConfig { - /** - * Rate limit retry configuration - */ - rateLimitRetry?: RateLimitFetchConfig; -} -``` - -## Examples - -### Example 1: Safe API Calls with Rate Limiting - -```typescript -import { Client, LagoRateLimitError } from "lago-javascript-client"; - -const client = Client("sk_live_xxxx", { - rateLimitRetry: { - maxRetries: 5, - retryOnRateLimit: true, - }, -}); - -async function getCustomer(id: string) { - try { - return await client.customers.findCustomer(id); - } catch (error) { - if (error instanceof LagoRateLimitError) { - // This shouldn't happen if retryOnRateLimit is true and maxRetries isn't exceeded - console.error("Rate limit exceeded after retries:", error.message); - } else { - console.error("API error:", error); - } - } -} -``` - -### Example 2: Batch Processing with Rate Limit Awareness - -```typescript -async function processCustomers(ids: string[]) { - const client = Client("sk_live_xxxx", { - rateLimitRetry: { - maxRetries: 3, - retryOnRateLimit: true, - }, - }); - - for (const id of ids) { - try { - const customer = await client.customers.findCustomer(id); - console.log(`Processed: ${customer.customer.external_id}`); - } catch (error) { - if (error instanceof LagoRateLimitError) { - console.log( - `Rate limited. Limit: ${error.limit}, Remaining: ${error.remaining}` - ); - // Client will auto-retry, but if maxRetries is exceeded: - console.log(`Next available request in ${error.reset}s`); - } - } - } -} -``` - -### Example 3: Custom Retry Logic - -```typescript -import { createRateLimitFetch } from "lago-javascript-client"; - -const customFetch = createRateLimitFetch(globalThis.fetch, { - maxRetries: 10, - retryOnRateLimit: true, -}); - -const client = Client("sk_live_xxxx", { - customFetch, // Use the custom fetch with rate limit handling -}); -``` - -### Example 4: Rate Limit Header Inspection - -```typescript -import { parseRateLimitHeaders } from "lago-javascript-client"; - -const response = await fetch("https://api.getlago.com/api/v1/customers"); -const limits = parseRateLimitHeaders(response.headers); - -console.log(`Limit: ${limits.limit} requests per window`); -console.log(`Remaining: ${limits.remaining}`); -console.log(`Reset in: ${limits.reset}s`); - -if (limits.remaining === 0) { - console.warn("Rate limit exhausted!"); -} -``` - -## Implementation Details - -### Architecture - -The rate limiting implementation consists of: - -1. **`LagoRateLimitError`**: Custom error class for 429 responses -2. **`rate_limit_headers.ts`**: Header parsing utilities -3. **`rate_limit_fetch.ts`**: Fetch wrapper with automatic retry logic -4. **`rate_limit_retry.ts`**: Advanced retry handler (extensible for custom use) -5. **Integration in `mod.ts`**: Seamless client integration - -### How It Works - -1. When the client makes a request, it uses the rate-limit-aware fetch -2. If a 429 response is received: - - Rate limit headers are parsed - - A `LagoRateLimitError` is created with retry timing information - - If `retryOnRateLimit` is true, the client sleeps for the appropriate duration and retries - - If max retries are exceeded, the error is thrown -3. All other responses (including errors) are passed through unchanged - -### No Generated Code Modification - -The implementation: -- Does NOT modify the generated OpenAPI client code -- Uses a fetch wrapper to intercept HTTP responses -- Is completely backward compatible -- Works with any ApiConfig options -- Respects existing customFetch implementations - -## Troubleshooting - -### Requests Still Timing Out - -If requests are taking a long time, they might be retrying due to rate limiting: - -```typescript -rateLimitRetry: { - maxRetries: 1, // Reduce retries for faster failure - retryOnRateLimit: true, -} -``` - -### Want to Handle Rate Limits Manually - -Disable auto-retry and handle errors yourself: - -```typescript -const client = Client("sk_live_xxxx", { - rateLimitRetry: { - retryOnRateLimit: false, - }, -}); - -try { - await client.customers.findCustomer(id); -} catch (error) { - if (error instanceof LagoRateLimitError) { - // Handle rate limit with your own logic - } -} -``` - -### Rate Limit Headers Not Available - -If headers are not being parsed, check that: -1. The server is returning the rate limit headers -2. Your proxy/middleware isn't stripping headers -3. The header names match exactly (lowercase): `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` - -## Testing - -Rate limiting can be tested using the mock fetch from the test utilities: - -```typescript -import { mf } from "../dev_deps.ts"; -import { createRateLimitFetch } from "../mod.ts"; - -const { fetch, mock } = mf.sandbox(); - -mock("https://example.com/api", () => { - return new Response("Rate limited", { - status: 429, - headers: { - "x-ratelimit-limit": "100", - "x-ratelimit-remaining": "0", - "x-ratelimit-reset": "60", - }, - }); -}); - -const rateLimitFetch = createRateLimitFetch(fetch, { - retryOnRateLimit: true, - maxRetries: 3, -}); - -const response = await rateLimitFetch("https://example.com/api"); -``` diff --git a/examples/rate_limiting_example.ts b/examples/rate_limiting_example.ts deleted file mode 100644 index 4b7626a..0000000 --- a/examples/rate_limiting_example.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Example: Using Rate Limiting with Lago Client - * - * This example demonstrates how to use the rate limiting features - * of the Lago JavaScript/TypeScript client. - */ - -import { Client, LagoRateLimitError, parseRateLimitHeaders } from "../mod.ts"; - -/** - * Example 1: Basic usage with automatic retry - */ -async function example1_BasicRateLimitRetry() { - console.log("Example 1: Basic rate limit retry\n"); - - // Initialize client with rate limit retry enabled - const client = Client("sk_live_xxx_your_api_key", { - rateLimitRetry: { - maxRetries: 3, - retryOnRateLimit: true, - }, - }); - - try { - // If this request gets rate limited, it will automatically retry - // after waiting for the reset time specified in the response headers - const customers = await client.customers.findCustomers(); - console.log(`Successfully fetched customers`); - } catch (error) { - if (error instanceof LagoRateLimitError) { - console.error(`Rate limit error: ${error.message}`); - console.error(`Limit: ${error.limit}, Remaining: ${error.remaining}`); - console.error(`Reset in: ${error.reset}s`); - } else { - console.error("Other error:", error); - } - } -} - -/** - * Example 2: Manual rate limit handling - */ -async function example2_ManualRateLimitHandling() { - console.log("\nExample 2: Manual rate limit handling\n"); - - // Disable automatic retry to handle rate limits manually - const client = Client("sk_live_xxx_your_api_key", { - rateLimitRetry: { - retryOnRateLimit: false, // Will throw instead of retry - }, - }); - - try { - const customers = await client.customers.findCustomers(); - console.log("Successfully fetched customers"); - } catch (error) { - if (error instanceof LagoRateLimitError) { - // Handle the rate limit error with custom logic - console.warn( - `Rate limited. Please retry after ${error.reset} seconds.` - ); - - // You could implement custom retry logic here - await sleep(error.reset * 1000); - // Retry the request... - } else { - console.error("Other error:", error); - } - } -} - -/** - * Example 3: Batch processing with rate limit awareness - */ -async function example3_BatchProcessing() { - console.log("\nExample 3: Batch processing\n"); - - const client = Client("sk_live_xxx_your_api_key", { - rateLimitRetry: { - maxRetries: 5, - retryOnRateLimit: true, - }, - }); - - const customerIds = [ - "cust_001", - "cust_002", - "cust_003", - "cust_004", - "cust_005", - ]; - - for (const id of customerIds) { - try { - const customer = await client.customers.findCustomer(id); - console.log( - `Processed customer: ${customer.customer.external_id}` - ); - } catch (error) { - if (error instanceof LagoRateLimitError) { - console.error( - `Rate limited. Remaining: ${error.remaining}/${error.limit}` - ); - console.error( - `Next request possible in ${error.reset}s` - ); - - // Optional: implement exponential backoff or jitter - // for distributed processing - } else { - console.error(`Error processing customer ${id}:`, error); - } - } - } -} - -/** - * Example 4: Inspecting rate limit headers directly - */ -async function example4_InspectRateLimitHeaders() { - console.log("\nExample 4: Inspect rate limit headers\n"); - - // Create a client without automatic retry - const client = Client("sk_live_xxx_your_api_key", { - rateLimitRetry: { - retryOnRateLimit: false, - }, - }); - - try { - // Make a request - note: this is pseudo-code since we can't - // directly access Response objects from the Lago API - // In real usage, you'd need to wrap the client methods - - console.log("This example would typically wrap the fetch layer"); - console.log("to inspect response headers directly"); - } catch (error) { - console.error("Error:", error); - } -} - -/** - * Example 5: Advanced configuration with custom retry limits - */ -async function example5_AdvancedConfiguration() { - console.log("\nExample 5: Advanced configuration\n"); - - // Different configs for different use cases - const configs = { - // Conservative: Few retries for time-sensitive operations - conservative: { - maxRetries: 1, - retryOnRateLimit: true, - }, - - // Moderate: Default retry behavior - moderate: { - maxRetries: 3, - retryOnRateLimit: true, - }, - - // Aggressive: Many retries for critical operations - aggressive: { - maxRetries: 10, - retryOnRateLimit: true, - }, - - // Manual: No automatic retry - manual: { - retryOnRateLimit: false, - }, - }; - - // Use conservative approach for time-sensitive operations - const timeSensitiveClient = Client( - "sk_live_xxx_your_api_key", - { - rateLimitRetry: configs.conservative, - } - ); - - // Use aggressive approach for critical batch operations - const batchClient = Client( - "sk_live_xxx_your_api_key", - { - rateLimitRetry: configs.aggressive, - } - ); - - console.log("Configured clients with different retry strategies"); - console.log("Conservative (1 retry): time-sensitive operations"); - console.log("Aggressive (10 retries): batch operations"); -} - -/** - * Example 6: Error handling best practices - */ -async function example6_ErrorHandlingBestPractices() { - console.log("\nExample 6: Error handling best practices\n"); - - const client = Client("sk_live_xxx_your_api_key", { - rateLimitRetry: { - maxRetries: 3, - retryOnRateLimit: true, - }, - }); - - async function makeApiRequest() { - try { - return await client.customers.findCustomers(); - } catch (error) { - // Handle rate limit errors - if (error instanceof LagoRateLimitError) { - console.error("Rate limit exceeded:"); - console.error(` Limit: ${error.limit} requests per window`); - console.error(` Remaining: ${error.remaining}`); - console.error(` Reset in: ${error.reset} seconds`); - - // Option 1: Implement exponential backoff - const backoffMs = Math.pow(2, 3) * 1000; // 8 seconds - console.log(`Waiting ${backoffMs}ms before retry...`); - await sleep(backoffMs); - - // Option 2: Notify monitoring/alerting system - console.log("Alert: Rate limit exhausted - check API quota"); - - // Option 3: Gracefully degrade service - return null; // Return cached data or default value - } - - // Handle other API errors - if (error instanceof Error && error.message.includes("Unauthorized")) { - console.error("Authentication failed - check your API key"); - return null; - } - - // Handle network errors - if (error instanceof TypeError) { - console.error("Network error - check connectivity"); - return null; - } - - // Unknown error - throw error; - } - } - - const result = await makeApiRequest(); - console.log("Request completed with robust error handling"); -} - -/** - * Helper function: sleep - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Run all examples - */ -async function runExamples() { - console.log("========================================"); - console.log("Lago Client Rate Limiting Examples"); - console.log("========================================\n"); - - try { - // Note: These examples are pseudo-code and would require - // actual API calls to work properly - - console.log("Example code loaded successfully!"); - console.log("\nTo use these examples:"); - console.log( - "1. Replace 'sk_live_xxx_your_api_key' with your actual API key" - ); - console.log( - "2. Uncomment the example you want to run" - ); - console.log("3. Run the script\n"); - - // Uncomment to run examples: - // await example1_BasicRateLimitRetry(); - // await example2_ManualRateLimitHandling(); - // await example3_BatchProcessing(); - // await example4_InspectRateLimitHeaders(); - // await example5_AdvancedConfiguration(); - // await example6_ErrorHandlingBestPractices(); - } catch (error) { - console.error("Error running examples:", error); - } -} - -// Only run examples if this file is executed directly -if (import.meta.main) { - runExamples(); -} - -export { - example1_BasicRateLimitRetry, - example2_ManualRateLimitHandling, - example3_BatchProcessing, - example4_InspectRateLimitHeaders, - example5_AdvancedConfiguration, - example6_ErrorHandlingBestPractices, -}; diff --git a/mod.ts b/mod.ts index 5a6c77e..bd9af26 100644 --- a/mod.ts +++ b/mod.ts @@ -65,10 +65,6 @@ 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 { - RateLimitRetryHandler, - type RateLimitRetryConfig, -} from "./rate_limit_retry.ts"; export { createRateLimitFetch, type RateLimitFetchConfig } from "./rate_limit_fetch.ts"; export * from "./openapi/client.ts"; diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts index d0e3369..af65b6e 100644 --- a/rate_limit_fetch.ts +++ b/rate_limit_fetch.ts @@ -40,7 +40,7 @@ export function createRateLimitFetch( const headers = parseRateLimitHeaders(response.headers); const limit = headers.limit ?? -1; const remaining = headers.remaining ?? 0; - const reset = headers.reset ?? 60; + const reset = headers.reset ?? -1; const error = new LagoRateLimitError(limit, remaining, reset); @@ -84,8 +84,15 @@ export function createRateLimitFetch( * Uses the exact reset time from headers if available, otherwise exponential backoff */ function getWaitTime(error: LagoRateLimitError, attempt: number): number { - // Use the exact reset time from the header - let waitMs = error.retryAfter; + let waitMs: number; + + 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); + } // Add small jitter to prevent thundering herd (max 100ms) const jitter = Math.random() * 100; diff --git a/rate_limit_retry.ts b/rate_limit_retry.ts deleted file mode 100644 index 38ecaa8..0000000 --- a/rate_limit_retry.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { LagoRateLimitError } from "./rate_limit_error.ts"; -import { parseRateLimitHeaders } from "./rate_limit_headers.ts"; - -/** - * Configuration for rate limit retry behavior - */ -export interface RateLimitRetryConfig { - /** Maximum number of retries on 429 (default: 3) */ - maxRetries?: number; - /** Whether to automatically retry on rate limit (default: true) */ - retryOnRateLimit?: boolean; -} - -/** - * Retry handler for rate-limited requests - * Handles 429 responses with automatic retry based on rate limit headers - */ -export class RateLimitRetryHandler { - private maxRetries: number; - private retryOnRateLimit: boolean; - - constructor(config: RateLimitRetryConfig = {}) { - this.maxRetries = config.maxRetries ?? 3; - this.retryOnRateLimit = config.retryOnRateLimit ?? true; - } - - /** - * Handle a response that may have rate limiting - * Throws LagoRateLimitError on 429, or rethrows if retryOnRateLimit is false - */ - async handleResponse(response: Response): Promise { - if (response.status === 429) { - const headers = parseRateLimitHeaders(response.headers); - - // Parse rate limit headers to create error - const limit = headers.limit ?? -1; - const remaining = headers.remaining ?? 0; - const reset = headers.reset ?? 60; // Default to 60s if not provided - - if (!this.retryOnRateLimit) { - throw new LagoRateLimitError(limit, remaining, reset); - } - - throw new LagoRateLimitError(limit, remaining, reset); - } - - return response; - } - - /** - * Sleep for a specified number of milliseconds - */ - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Retry a request function with exponential backoff on rate limit - */ - async retryWithBackoff( - requestFn: () => Promise, - isRateLimitError: (error: unknown) => boolean = (e) => - e instanceof LagoRateLimitError, - ): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt <= this.maxRetries; attempt++) { - try { - return await requestFn(); - } catch (error) { - lastError = error; - - if (!isRateLimitError(error)) { - throw error; // Not a rate limit error, rethrow immediately - } - - if (attempt === this.maxRetries) { - throw error; // Max retries reached - } - - // Calculate wait time - let waitMs: number; - if (error instanceof LagoRateLimitError) { - // 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); - } - - // Add small jitter to prevent thundering herd - const jitter = Math.random() * 100; - await this.sleep(waitMs + jitter); - } - } - - throw lastError; - } -} diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts index 5288a1d..7aca6b3 100644 --- a/tests/rate_limit.test.ts +++ b/tests/rate_limit.test.ts @@ -166,6 +166,40 @@ Deno.test("createRateLimitFetch respects maxRetries", async () => { } }); +Deno.test("createRateLimitFetch uses exponential backoff when reset header missing", async () => { + let callCount = 0; + + const mockFetch = createMockFetch(() => { + callCount++; + if (callCount === 1) { + // 429 without reset header + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + }, + }); + } + return new Response('{"success": true}', { status: 200 }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: true, + maxRetries: 3, + }); + + const startTime = Date.now(); + const response = await rateLimitFetch("https://example.com/api"); + const elapsed = Date.now() - startTime; + + assertEquals(response.status, 200); + assertEquals(callCount, 2); + // Exponential backoff attempt 0: 1000ms * 2^0 = 1000ms + jitter + assertEquals(elapsed >= 1000, true); + assertEquals(elapsed < 1500, true); // Should not be as long as the old 60s default +}); + Deno.test("createRateLimitFetch passes through non-429 errors", async () => { const mockFetch = createMockFetch(() => { return new Response("Server error", { From 6e9ac9f0a025897a65c5de13ea5a7db52ddf0fa5 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Mon, 13 Apr 2026 10:27:19 +0200 Subject: [PATCH 07/10] chore: remove deno.lock incompatible with CI Deno v1.x Local Deno generated lockfile v5 which is unsupported by CI's Deno 1.46.3. Removing so CI can regenerate. Co-Authored-By: Claude Opus 4.6 --- deno.lock | 132 ------------------------------------------------------ 1 file changed, 132 deletions(-) delete mode 100644 deno.lock diff --git a/deno.lock b/deno.lock deleted file mode 100644 index df0be00..0000000 --- a/deno.lock +++ /dev/null @@ -1,132 +0,0 @@ -{ - "version": "5", - "redirects": { - "https://crux.land/api/get/2KNRVU": "https://crux.land/api/get/2KNRVU.ts", - "https://crux.land/api/get/router@0.0.5": "https://crux.land/api/get/2KNRVU", - "https://crux.land/router@0.0.5": "https://crux.land/api/get/router@0.0.5" - }, - "remote": { - "https://crux.land/api/get/2KNRVU.ts": "6a77d55844aba78d01520c5ff0b2f0af7f24cc1716a0de8b3bb6bd918c47b5ba", - "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", - "https://deno.land/std@0.140.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", - "https://deno.land/std@0.140.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", - "https://deno.land/std@0.140.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", - "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", - "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", - "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.140.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", - "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", - "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.140.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", - "https://deno.land/std@0.140.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.140.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", - "https://deno.land/std@0.140.0/path/mod.ts": "d3e68d0abb393fb0bf94a6d07c46ec31dc755b544b13144dee931d8d5f06a52d", - "https://deno.land/std@0.140.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", - "https://deno.land/std@0.140.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.140.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", - "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", - "https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.181.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.181.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.181.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", - "https://deno.land/std@0.181.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.181.0/fs/walk.ts": "ea95ffa6500c1eda6b365be488c056edc7c883a1db41ef46ec3bf057b1c0fe32", - "https://deno.land/std@0.181.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.181.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.181.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.181.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.181.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.181.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.181.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.181.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.181.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.182.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.182.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.182.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.182.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", - "https://deno.land/std@0.182.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.182.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", - "https://deno.land/std@0.182.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.182.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.182.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.182.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.182.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.182.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", - "https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", - "https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", - "https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", - "https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", - "https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c", - "https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", - "https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", - "https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", - "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", - "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", - "https://deno.land/x/deno_cache@0.4.1/cache.ts": "51f72f4299411193d780faac8c09d4e8cbee951f541121ef75fcc0e94e64c195", - "https://deno.land/x/deno_cache@0.4.1/deno_dir.ts": "f2a9044ce8c7fe1109004cda6be96bf98b08f478ce77e7a07f866eff1bdd933f", - "https://deno.land/x/deno_cache@0.4.1/deps.ts": "8974097d6c17e65d9a82d39377ae8af7d94d74c25c0cbb5855d2920e063f2343", - "https://deno.land/x/deno_cache@0.4.1/dirs.ts": "d2fa473ef490a74f2dcb5abb4b9ab92a48d2b5b6320875df2dee64851fa64aa9", - "https://deno.land/x/deno_cache@0.4.1/disk_cache.ts": "1f3f5232cba4c56412d93bdb324c624e95d5dd179d0578d2121e3ccdf55539f9", - "https://deno.land/x/deno_cache@0.4.1/file_fetcher.ts": "07a6c5f8fd94bf50a116278cc6012b4921c70d2251d98ce1c9f3c352135c39f7", - "https://deno.land/x/deno_cache@0.4.1/http_cache.ts": "f632e0d6ec4a5d61ae3987737a72caf5fcdb93670d21032ddb78df41131360cd", - "https://deno.land/x/deno_cache@0.4.1/mod.ts": "ef1cda9235a93b89cb175fe648372fc0f785add2a43aa29126567a05e3e36195", - "https://deno.land/x/deno_cache@0.4.1/util.ts": "8cb686526f4be5205b92c819ca2ce82220aa0a8dd3613ef0913f6dc269dbbcfe", - "https://deno.land/x/deno_graph@0.26.0/lib/deno_graph.generated.js": "2f7ca85b2ceb80ec4b3d1b7f3a504956083258610c7b9a1246238c5b7c68f62d", - "https://deno.land/x/deno_graph@0.26.0/lib/loader.ts": "380e37e71d0649eb50176a9786795988fc3c47063a520a54b616d7727b0f8629", - "https://deno.land/x/deno_graph@0.26.0/lib/media_type.ts": "222626d524fa2f9ebcc0ec7c7a7d5dfc74cc401cc46790f7c5e0eab0b0787707", - "https://deno.land/x/deno_graph@0.26.0/lib/snippets/deno_graph-de651bc9c240ed8d/src/deno_apis.js": "41192baaa550a5c6a146280fae358cede917ae16ec4e4315be51bef6631ca892", - "https://deno.land/x/deno_graph@0.26.0/mod.ts": "11131ae166580a1c7fa8506ff553751465a81c263d94443f18f353d0c320bc14", - "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts": "91eb1c4bfadfbeda30171007bac6d85aadacd43224a5ed721bbe56bc64e9eb66", - "https://deno.land/x/dnt@0.38.0/lib/compiler.ts": "209ad2e1b294f93f87ec02ade9a0821f942d2e524104552d0aa8ff87021050a5", - "https://deno.land/x/dnt@0.38.0/lib/compiler_transforms.ts": "f21aba052f5dcf0b0595c734450842855c7f572e96165d3d34f8fed2fc1f7ba1", - "https://deno.land/x/dnt@0.38.0/lib/mod.deps.ts": "30367fc68bcd2acf3b7020cf5cdd26f817f7ac9ac35c4bfb6c4551475f91bc3e", - "https://deno.land/x/dnt@0.38.0/lib/npm_ignore.ts": "57fbb7e7b935417d225eec586c6aa240288905eb095847d3f6a88e290209df4e", - "https://deno.land/x/dnt@0.38.0/lib/package_json.ts": "61f35b06e374ed39ca776d29d67df4be7ee809d0bca29a8239687556c6d027c2", - "https://deno.land/x/dnt@0.38.0/lib/pkg/dnt_wasm.generated.js": "82aeecfb055af0b2700e1e9b886e4a44fe3bf9cd11a9c4195cb169f53a134b15", - "https://deno.land/x/dnt@0.38.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "a6b95adc943a68d513fe8ed9ec7d260ac466b7a4bced4e942f733e494bb9f1be", - "https://deno.land/x/dnt@0.38.0/lib/shims.ts": "df1bd4d9a196dca4b2d512b1564fff64ac6c945189a273d706391f87f210d7e6", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/get_test_runner_code.ts": "4dc7a73a13b027341c0688df2b29a4ef102f287c126f134c33f69f0339b46968", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/test_runner.ts": "4d0da0500ec427d5f390d9a8d42fb882fbeccc92c92d66b6f2e758606dbd40e6", - "https://deno.land/x/dnt@0.38.0/lib/transform.deps.ts": "e42f2bdef46d098453bdba19261a67cf90b583f5d868f7fe83113c1380d9b85c", - "https://deno.land/x/dnt@0.38.0/lib/types.ts": "b8e228b2fac44c2ae902fbb73b1689f6ab889915bd66486c8a85c0c24255f5fb", - "https://deno.land/x/dnt@0.38.0/lib/utils.ts": "878b7ac7003a10c16e6061aa49dbef9b42bd43174853ebffc9b67ea47eeb11d8", - "https://deno.land/x/dnt@0.38.0/mod.ts": "b13349fe77847cf58e26b40bcd58797a8cec5d71b31a1ca567071329c8489de1", - "https://deno.land/x/dnt@0.38.0/transform.ts": "f68743a14cf9bf53bfc9c81073871d69d447a7f9e3453e0447ca2fb78926bb1d", - "https://deno.land/x/mock_fetch@0.3.0/mod.ts": "7e7806c65ab17b2b684c334c4e565812bdaf504a3e9c938d2bb52bb67428bc89", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", - "https://deno.land/x/ts_morph@18.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", - "https://deno.land/x/ts_morph@18.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@18.0.0/common/ts_morph_common.js": "845671ca951073400ce142f8acefa2d39ea9a51e29ca80928642f3f8cf2b7700", - "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59", - "https://deno.land/x/wasmbuild@0.14.1/cache.ts": "89eea5f3ce6035a1164b3e655c95f21300498920575ade23161421f5b01967f4", - "https://deno.land/x/wasmbuild@0.14.1/loader.ts": "d98d195a715f823151cbc8baa3f32127337628379a02d9eb2a3c5902dbccfc02" - } -} From 3f3ad9270a0e5f0e9540978b44cb282962231757 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Mon, 13 Apr 2026 14:18:52 +0200 Subject: [PATCH 08/10] chore: add .claude to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6d0a14c..0570e62 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ web_modules/ npm/ openapi/ ARCHITECTURE.md +.claude From ca03802c62b232686d0025ff544da8736bd0228a Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Mon, 13 Apr 2026 14:28:59 +0200 Subject: [PATCH 09/10] feat: add 20s max retry delay cap Prevents excessively long waits when the server returns a large x-ratelimit-reset value. Applies to both header-based and exponential backoff delays. Co-Authored-By: Claude Opus 4.6 --- deno.lock | 40 ++++++++++++++++++++++++++++++++++++++++ rate_limit_fetch.ts | 14 ++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 deno.lock diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..1d7ad03 --- /dev/null +++ b/deno.lock @@ -0,0 +1,40 @@ +{ + "version": "5", + "redirects": { + "https://crux.land/api/get/2KNRVU": "https://crux.land/api/get/2KNRVU.ts", + "https://crux.land/api/get/router@0.0.5": "https://crux.land/api/get/2KNRVU", + "https://crux.land/router@0.0.5": "https://crux.land/api/get/router@0.0.5" + }, + "remote": { + "https://crux.land/api/get/2KNRVU.ts": "6a77d55844aba78d01520c5ff0b2f0af7f24cc1716a0de8b3bb6bd918c47b5ba", + "https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", + "https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", + "https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", + "https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", + "https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c", + "https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", + "https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", + "https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", + "https://deno.land/x/mock_fetch@0.3.0/mod.ts": "7e7806c65ab17b2b684c334c4e565812bdaf504a3e9c938d2bb52bb67428bc89" + } +} diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts index af65b6e..13ddfa8 100644 --- a/rate_limit_fetch.ts +++ b/rate_limit_fetch.ts @@ -12,6 +12,8 @@ export interface RateLimitFetchConfig { maxRetries?: number; /** Whether to automatically retry on rate limit (default: true) */ retryOnRateLimit?: boolean; + /** Maximum delay in milliseconds before a retry (default: 20000) */ + maxRetryDelay?: number; } /** @@ -24,6 +26,7 @@ export function createRateLimitFetch( ): typeof fetch { const maxRetries = config.maxRetries ?? 3; const retryOnRateLimit = config.retryOnRateLimit ?? true; + const maxRetryDelay = config.maxRetryDelay ?? 20_000; return async function rateLimitFetch( input: RequestInfo | URL, @@ -53,7 +56,7 @@ export function createRateLimitFetch( } // Wait before retry - const waitMs = getWaitTime(error, attempt); + const waitMs = getWaitTime(error, attempt, maxRetryDelay); await sleep(waitMs); continue; // Retry } @@ -83,7 +86,11 @@ export function createRateLimitFetch( * Calculate wait time before retry * Uses the exact reset time from headers if available, otherwise exponential backoff */ -function getWaitTime(error: LagoRateLimitError, attempt: number): number { +function getWaitTime( + error: LagoRateLimitError, + attempt: number, + maxRetryDelay: number, +): number { let waitMs: number; if (error.reset > 0) { @@ -94,6 +101,9 @@ function getWaitTime(error: LagoRateLimitError, attempt: number): number { 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; From 99c20c4a84a1ba177736866fc1c840006141b509 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Mon, 13 Apr 2026 14:39:39 +0200 Subject: [PATCH 10/10] fix: remove deno.lock and add to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + deno.lock | 40 ---------------------------------------- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 deno.lock diff --git a/.gitignore b/.gitignore index 0570e62..aa9285a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,4 @@ npm/ openapi/ ARCHITECTURE.md .claude +deno.lock diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 1d7ad03..0000000 --- a/deno.lock +++ /dev/null @@ -1,40 +0,0 @@ -{ - "version": "5", - "redirects": { - "https://crux.land/api/get/2KNRVU": "https://crux.land/api/get/2KNRVU.ts", - "https://crux.land/api/get/router@0.0.5": "https://crux.land/api/get/2KNRVU", - "https://crux.land/router@0.0.5": "https://crux.land/api/get/router@0.0.5" - }, - "remote": { - "https://crux.land/api/get/2KNRVU.ts": "6a77d55844aba78d01520c5ff0b2f0af7f24cc1716a0de8b3bb6bd918c47b5ba", - "https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", - "https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", - "https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", - "https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", - "https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", - "https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c", - "https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", - "https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", - "https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", - "https://deno.land/x/mock_fetch@0.3.0/mod.ts": "7e7806c65ab17b2b684c334c4e565812bdaf504a3e9c938d2bb52bb67428bc89" - } -}