Skip to content

Commit 33c421c

Browse files
committed
feat: add HTTP client and rate limiting utilities
1 parent fd1d1d9 commit 33c421c

File tree

4 files changed

+248
-0
lines changed

4 files changed

+248
-0
lines changed

src/utils/http.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { HttpError, fetchWithTimeout, parseJsonResponse } from './http.js';
3+
4+
// Mock global fetch
5+
global.fetch = vi.fn();
6+
7+
describe('fetchWithTimeout', () => {
8+
afterEach(() => {
9+
vi.clearAllMocks();
10+
vi.restoreAllMocks();
11+
});
12+
13+
it('should make a successful request', async () => {
14+
const mockResponse = new Response('Success', { status: 200 });
15+
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
16+
17+
const response = await fetchWithTimeout('https://example.com');
18+
expect(response).toBe(mockResponse);
19+
expect(fetch).toHaveBeenCalledWith('https://example.com', {
20+
signal: expect.any(AbortSignal),
21+
});
22+
});
23+
24+
it('should handle HTTP errors', async () => {
25+
const mockResponse = new Response('Not Found', {
26+
status: 404,
27+
statusText: 'Not Found',
28+
});
29+
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
30+
31+
await expect(fetchWithTimeout('https://example.com')).rejects.toThrow(HttpError);
32+
});
33+
34+
it('should handle timeout', async () => {
35+
// Create an abort controller to simulate timeout
36+
const abortError = new Error('The operation was aborted');
37+
abortError.name = 'AbortError';
38+
39+
vi.mocked(fetch).mockRejectedValueOnce(abortError);
40+
41+
await expect(fetchWithTimeout('https://example.com', { timeout: 100 })).rejects.toThrow(
42+
'Request timeout after 100ms',
43+
);
44+
});
45+
46+
it('should handle network errors', async () => {
47+
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
48+
49+
await expect(fetchWithTimeout('https://example.com')).rejects.toThrow('Network error');
50+
});
51+
});
52+
53+
describe('parseJsonResponse', () => {
54+
it('should parse valid JSON response', async () => {
55+
const data = { test: 'value' };
56+
const response = new Response(JSON.stringify(data));
57+
58+
const result = await parseJsonResponse(response);
59+
expect(result).toEqual(data);
60+
});
61+
62+
it('should handle invalid JSON', async () => {
63+
const response = new Response('invalid json');
64+
65+
await expect(parseJsonResponse(response)).rejects.toThrow('Failed to parse JSON response');
66+
});
67+
});

src/utils/http.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* HTTP utilities for making API calls to the Wayback Machine
3+
*/
4+
5+
interface FetchOptions {
6+
method?: string;
7+
headers?: Record<string, string>;
8+
body?: string;
9+
timeout?: number;
10+
}
11+
12+
export class HttpError extends Error {
13+
constructor(
14+
message: string,
15+
public readonly status?: number,
16+
public readonly response?: string,
17+
) {
18+
super(message);
19+
this.name = 'HttpError';
20+
}
21+
}
22+
23+
/**
24+
* Wrapper around fetch with timeout support and error handling
25+
*/
26+
export async function fetchWithTimeout(url: string, options: FetchOptions = {}): Promise<Response> {
27+
const { timeout = 30000, ...fetchOptions } = options;
28+
29+
const controller = new AbortController();
30+
const timeoutId = setTimeout(() => controller.abort(), timeout);
31+
32+
try {
33+
const response = await fetch(url, {
34+
...fetchOptions,
35+
signal: controller.signal,
36+
});
37+
38+
clearTimeout(timeoutId);
39+
40+
if (!response.ok) {
41+
const text = await response.text().catch(() => '');
42+
throw new HttpError(
43+
`HTTP ${response.status}: ${response.statusText}`,
44+
response.status,
45+
text,
46+
);
47+
}
48+
49+
return response;
50+
} catch (error) {
51+
clearTimeout(timeoutId);
52+
53+
if (error instanceof Error) {
54+
if (error.name === 'AbortError') {
55+
throw new HttpError(`Request timeout after ${timeout}ms`);
56+
}
57+
throw error;
58+
}
59+
60+
throw new HttpError('Network error occurred');
61+
}
62+
}
63+
64+
/**
65+
* Parse JSON response with error handling
66+
*/
67+
export async function parseJsonResponse<T>(response: Response): Promise<T> {
68+
try {
69+
return (await response.json()) as T;
70+
} catch (error) {
71+
throw new HttpError('Failed to parse JSON response');
72+
}
73+
}

src/utils/rate-limit.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { RateLimiter } from './rate-limit.js';
3+
4+
describe('RateLimiter', () => {
5+
let rateLimiter: RateLimiter;
6+
7+
beforeEach(() => {
8+
rateLimiter = new RateLimiter({
9+
maxRequests: 3,
10+
windowMs: 1000, // 1 second
11+
});
12+
vi.useFakeTimers();
13+
});
14+
15+
it('should allow requests within limit', () => {
16+
expect(rateLimiter.canMakeRequest()).toBe(true);
17+
rateLimiter.recordRequest();
18+
expect(rateLimiter.canMakeRequest()).toBe(true);
19+
rateLimiter.recordRequest();
20+
expect(rateLimiter.canMakeRequest()).toBe(true);
21+
rateLimiter.recordRequest();
22+
expect(rateLimiter.canMakeRequest()).toBe(false);
23+
});
24+
25+
it('should reset after window expires', () => {
26+
rateLimiter.recordRequest();
27+
rateLimiter.recordRequest();
28+
rateLimiter.recordRequest();
29+
expect(rateLimiter.canMakeRequest()).toBe(false);
30+
31+
vi.advanceTimersByTime(1100);
32+
expect(rateLimiter.canMakeRequest()).toBe(true);
33+
});
34+
35+
it('should wait for slot when rate limited', async () => {
36+
rateLimiter.recordRequest();
37+
rateLimiter.recordRequest();
38+
rateLimiter.recordRequest();
39+
40+
const waitPromise = rateLimiter.waitForSlot();
41+
vi.advanceTimersByTime(1100);
42+
await vi.runAllTimersAsync();
43+
await expect(waitPromise).resolves.toBeUndefined();
44+
});
45+
});

src/utils/rate-limit.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Rate limiting utilities for Wayback Machine API
3+
*/
4+
5+
interface RateLimitOptions {
6+
maxRequests: number;
7+
windowMs: number;
8+
}
9+
10+
export class RateLimiter {
11+
private requests: number[] = [];
12+
private readonly maxRequests: number;
13+
private readonly windowMs: number;
14+
15+
constructor(options: RateLimitOptions) {
16+
this.maxRequests = options.maxRequests;
17+
this.windowMs = options.windowMs;
18+
}
19+
20+
/**
21+
* Check if a request can be made without violating rate limits
22+
*/
23+
canMakeRequest(): boolean {
24+
this.cleanup();
25+
return this.requests.length < this.maxRequests;
26+
}
27+
28+
/**
29+
* Wait until a request can be made
30+
*/
31+
async waitForSlot(): Promise<void> {
32+
while (!this.canMakeRequest()) {
33+
const oldestRequest = this.requests[0];
34+
const waitTime = oldestRequest + this.windowMs - Date.now();
35+
if (waitTime > 0) {
36+
await new Promise((resolve) => setTimeout(resolve, waitTime + 100));
37+
}
38+
this.cleanup();
39+
}
40+
}
41+
42+
/**
43+
* Record a request
44+
*/
45+
recordRequest(): void {
46+
this.requests.push(Date.now());
47+
}
48+
49+
/**
50+
* Remove expired requests from the tracking array
51+
*/
52+
private cleanup(): void {
53+
const cutoff = Date.now() - this.windowMs;
54+
this.requests = this.requests.filter((time) => time > cutoff);
55+
}
56+
}
57+
58+
// Default rate limiter for Wayback Machine
59+
// Conservative limits to be respectful of the service
60+
export const waybackRateLimiter = new RateLimiter({
61+
maxRequests: 15, // 15 requests
62+
windowMs: 60000, // per minute
63+
});

0 commit comments

Comments
 (0)