Skip to content

Commit 0a5f08e

Browse files
committed
rate limit support
1 parent 017aefb commit 0a5f08e

3 files changed

Lines changed: 677 additions & 1 deletion

File tree

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Official Node.js SDK for the [Short.io](https://short.io) URL shortening and lin
2121
- [Advanced Configuration](#advanced-configuration)
2222
- [TypeScript Support](#typescript-support)
2323
- [Rate Limits](#rate-limits)
24+
- [Rate Limit Handling](#rate-limit-handling)
2425
- [Error Handling](#error-handling)
2526
- [Support](#support)
2627
- [License](#license)
@@ -38,6 +39,7 @@ Official Node.js SDK for the [Short.io](https://short.io) URL shortening and lin
3839
- **Permissions Management** - Control user access to links
3940
- **Full TypeScript Support** - Comprehensive type definitions included
4041
- **Modern ESM** - Built with ES modules
42+
- **Automatic Rate Limit Handling** - Built-in retry logic for 429 responses with exponential backoff
4143

4244
## Requirements
4345

@@ -701,6 +703,100 @@ interface Response<T> {
701703
| QR Bulk Generation | 1 request/minute |
702704
| Public API | 50 requests/second |
703705

706+
## Rate Limit Handling
707+
708+
The SDK provides optional automatic retry logic for HTTP 429 (Too Many Requests) responses with exponential backoff.
709+
710+
**Default behavior:** No automatic retries. When a 429 response is received, it is returned immediately (or thrown if `throwOnError` is enabled). You must explicitly enable rate limit handling to get automatic retries.
711+
712+
### Enable Global Rate Limiting
713+
714+
```javascript
715+
import { setApiKey, enableRateLimiting } from "@short.io/client-node";
716+
717+
setApiKey("YOUR_API_KEY");
718+
719+
// Enable with default settings
720+
enableRateLimiting();
721+
// Defaults: maxRetries=3, baseDelayMs=1000, maxDelayMs=60000
722+
723+
// Or customize the behavior
724+
enableRateLimiting({
725+
maxRetries: 5, // Maximum retry attempts (default: 3)
726+
baseDelayMs: 1000, // Initial delay in ms (default: 1000)
727+
maxDelayMs: 60000, // Maximum delay cap in ms (default: 60000)
728+
onRateLimited: (info) => {
729+
console.log(`Rate limited. Retry ${info.attempt} in ${info.delayMs}ms`);
730+
console.log(`Limit: ${info.rateLimitLimit}, Remaining: ${info.rateLimitRemaining}`);
731+
}
732+
});
733+
```
734+
735+
### Disable Rate Limiting
736+
737+
```javascript
738+
import { disableRateLimiting } from "@short.io/client-node";
739+
740+
disableRateLimiting();
741+
```
742+
743+
### Per-Request Rate Limiting
744+
745+
Create a rate-limited client for specific requests:
746+
747+
```javascript
748+
import { createRateLimitedClient, createLink } from "@short.io/client-node";
749+
750+
const client = createRateLimitedClient({
751+
maxRetries: 5,
752+
onRateLimited: (info) => console.log(`Retry ${info.attempt}...`)
753+
});
754+
755+
const result = await createLink({
756+
client,
757+
body: {
758+
originalURL: "https://example.com",
759+
domain: "your-domain.com"
760+
}
761+
});
762+
```
763+
764+
### Rate Limit Info
765+
766+
The `onRateLimited` callback receives detailed information:
767+
768+
```typescript
769+
interface RateLimitInfo {
770+
status: number; // HTTP status (429)
771+
attempt: number; // Current retry attempt (1, 2, 3...)
772+
delayMs: number; // Delay before next retry in ms
773+
retryAfter?: number; // Seconds from Retry-After header
774+
rateLimitLimit?: number; // Request limit per window (X-RateLimit-Limit)
775+
rateLimitRemaining?: number; // Remaining requests (X-RateLimit-Remaining)
776+
rateLimitReset?: number; // Unix timestamp when limit resets (X-RateLimit-Reset)
777+
request: Request; // The request that was rate limited
778+
}
779+
```
780+
781+
### Configuration Options
782+
783+
| Option | Default | Description |
784+
|--------|---------|-------------|
785+
| `maxRetries` | `3` | Maximum number of retry attempts before returning the 429 response |
786+
| `baseDelayMs` | `1000` | Initial delay in milliseconds for exponential backoff |
787+
| `maxDelayMs` | `60000` | Maximum delay cap in milliseconds (1 minute) |
788+
| `onRateLimited` | `undefined` | Optional callback invoked before each retry |
789+
790+
### Backoff Strategy
791+
792+
The SDK uses exponential backoff with jitter:
793+
794+
1. If `Retry-After` header is present, uses that value (capped at `maxDelayMs`)
795+
2. Otherwise, calculates delay as: `baseDelayMs * 2^(attempt-1) + random jitter`
796+
3. All delays are capped at `maxDelayMs`
797+
798+
After exhausting all retries, the 429 response is returned normally (or thrown if `throwOnError` is enabled).
799+
704800
## Error Handling
705801

706802
```javascript

src/index.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,183 @@
11
import { client } from "./generated/client.gen"
2+
import { createClient, createConfig } from "./generated/client"
3+
import type { Client } from "./generated/client"
24
export * from './generated/types.gen';
35
export * from './generated/sdk.gen';
46
export * from './generated/zod.gen';
57

8+
// ─── Rate Limit Types ────────────────────────────────────────────────────────
9+
10+
export interface RateLimitConfig {
11+
enabled?: boolean;
12+
maxRetries?: number;
13+
baseDelayMs?: number;
14+
maxDelayMs?: number;
15+
onRateLimited?: (info: RateLimitInfo) => void;
16+
}
17+
18+
export interface RateLimitInfo {
19+
status: number;
20+
attempt: number;
21+
delayMs: number;
22+
retryAfter?: number;
23+
rateLimitLimit?: number;
24+
rateLimitRemaining?: number;
25+
rateLimitReset?: number;
26+
request: Request;
27+
}
28+
29+
// ─── Rate Limit Internal State ───────────────────────────────────────────────
30+
31+
const DEFAULT_RATE_LIMIT_CONFIG: Required<Omit<RateLimitConfig, 'onRateLimited'>> & Pick<RateLimitConfig, 'onRateLimited'> = {
32+
enabled: true,
33+
maxRetries: 3,
34+
baseDelayMs: 1000,
35+
maxDelayMs: 60000,
36+
onRateLimited: undefined,
37+
};
38+
39+
let globalRateLimitConfig: RateLimitConfig | null = null;
40+
41+
// ─── Rate Limit Helper Functions ─────────────────────────────────────────────
42+
43+
function parseRateLimitHeaders(response: Response): Pick<RateLimitInfo, 'retryAfter' | 'rateLimitLimit' | 'rateLimitRemaining' | 'rateLimitReset'> {
44+
const retryAfterHeader = response.headers.get('Retry-After');
45+
const rateLimitLimit = response.headers.get('X-RateLimit-Limit');
46+
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining');
47+
const rateLimitReset = response.headers.get('X-RateLimit-Reset');
48+
49+
let retryAfter: number | undefined;
50+
if (retryAfterHeader) {
51+
const parsed = parseInt(retryAfterHeader, 10);
52+
if (!isNaN(parsed)) {
53+
retryAfter = parsed;
54+
} else {
55+
// Try parsing as HTTP-date
56+
const date = Date.parse(retryAfterHeader);
57+
if (!isNaN(date)) {
58+
retryAfter = Math.max(0, Math.ceil((date - Date.now()) / 1000));
59+
}
60+
}
61+
}
62+
63+
return {
64+
retryAfter,
65+
rateLimitLimit: rateLimitLimit ? parseInt(rateLimitLimit, 10) : undefined,
66+
rateLimitRemaining: rateLimitRemaining ? parseInt(rateLimitRemaining, 10) : undefined,
67+
rateLimitReset: rateLimitReset ? parseInt(rateLimitReset, 10) : undefined,
68+
};
69+
}
70+
71+
function calculateBackoffDelay(
72+
attempt: number,
73+
baseDelay: number,
74+
maxDelay: number,
75+
retryAfter?: number
76+
): number {
77+
if (retryAfter !== undefined) {
78+
return Math.min(retryAfter * 1000, maxDelay);
79+
}
80+
// Exponential backoff with jitter
81+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
82+
const jitter = Math.random() * 0.1 * exponentialDelay;
83+
return Math.min(exponentialDelay + jitter, maxDelay);
84+
}
85+
86+
export type SleepFunction = (ms: number) => Promise<void>;
87+
88+
export const defaultSleep: SleepFunction = (ms: number) =>
89+
new Promise(resolve => setTimeout(resolve, ms));
90+
91+
// ─── Rate Limit Fetch Wrapper ────────────────────────────────────────────────
92+
93+
export function createRateLimitFetch(
94+
originalFetch: typeof fetch,
95+
config: RateLimitConfig,
96+
sleepFn: SleepFunction = defaultSleep
97+
): typeof fetch {
98+
const mergedConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...config };
99+
100+
return async function rateLimitFetch(
101+
input: RequestInfo | URL,
102+
init?: RequestInit
103+
): Promise<Response> {
104+
let lastResponse: Response | undefined;
105+
106+
// If input is already a Request, clone it to preserve for retries
107+
// Clone once at the start to create a "template" we can clone for each attempt
108+
const templateRequest = input instanceof Request ? input.clone() : new Request(input, init);
109+
110+
for (let attempt = 1; attempt <= mergedConfig.maxRetries + 1; attempt++) {
111+
// Clone the template for each attempt
112+
const requestForAttempt = templateRequest.clone();
113+
const response = await originalFetch(requestForAttempt);
114+
115+
if (response.status !== 429) {
116+
return response;
117+
}
118+
119+
lastResponse = response;
120+
121+
if (attempt > mergedConfig.maxRetries) {
122+
break;
123+
}
124+
125+
const headerInfo = parseRateLimitHeaders(response);
126+
const delayMs = calculateBackoffDelay(
127+
attempt,
128+
mergedConfig.baseDelayMs,
129+
mergedConfig.maxDelayMs,
130+
headerInfo.retryAfter
131+
);
132+
133+
if (mergedConfig.onRateLimited) {
134+
const info: RateLimitInfo = {
135+
status: 429,
136+
attempt,
137+
delayMs,
138+
...headerInfo,
139+
request: templateRequest.clone(),
140+
};
141+
mergedConfig.onRateLimited(info);
142+
}
143+
144+
await sleepFn(delayMs);
145+
}
146+
147+
return lastResponse!;
148+
};
149+
}
150+
151+
// ─── Public API ──────────────────────────────────────────────────────────────
152+
153+
export function enableRateLimiting(config: Partial<RateLimitConfig> = {}): void {
154+
globalRateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...config, enabled: true };
155+
client.setConfig({
156+
fetch: createRateLimitFetch(globalThis.fetch, globalRateLimitConfig),
157+
});
158+
}
159+
160+
export function disableRateLimiting(): void {
161+
globalRateLimitConfig = null;
162+
client.setConfig({
163+
fetch: globalThis.fetch,
164+
});
165+
}
166+
167+
export function getRateLimitConfig(): Readonly<RateLimitConfig> | null {
168+
return globalRateLimitConfig ? { ...globalRateLimitConfig } : null;
169+
}
170+
171+
export function createRateLimitedClient(config: Partial<RateLimitConfig> = {}): Client {
172+
const mergedConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...config, enabled: true };
173+
return createClient(createConfig({
174+
baseUrl: 'https://api.short.io',
175+
fetch: createRateLimitFetch(globalThis.fetch, mergedConfig),
176+
}));
177+
}
178+
179+
// ─── Client Configuration ────────────────────────────────────────────────────
180+
6181
client.setConfig({
7182
baseUrl: "https://api.short.io"
8183
})

0 commit comments

Comments
 (0)