HTTP Client Patterns
Your AI agent calls multiple APIs: OpenAI for chat completions, Anthropic for Claude, and your own backend for user context. Each request needs authentication headers, error handling, and retry logic for rate limits. You could write this logic separately for each API, duplicating code across your codebase. Or you could build a single HTTP client that handles these concerns once, then wrap it for each AI provider.
The foundation for every AI SDK is the same: the fetch API. It's the universal HTTP client available in Node.js, Deno, Bun, browsers, and edge runtimes. When you master fetch and its supporting objects (Request, Response, Headers, AbortController), you can build HTTP clients that work anywhere TypeScript runs.
This lesson teaches the patterns that power production AI SDKs. You'll build a fetch wrapper with retry logic, timeout handling, and header management. In Chapter 76, you'll wrap this foundation with Zod validation and streaming support to create complete type-safe SDKs.
The Universal Fetch API
Every modern JavaScript runtime implements the same fetch function. This wasn't always true - Node.js only added native fetch in version 18 (2022). Before that, you needed libraries like axios or node-fetch. Now, the same code works everywhere:
// Works in Node.js, Deno, Bun, browsers, and edge functions
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
Output:
{ "message": "Hello from the API" }
Basic Fetch Patterns
A complete fetch call includes method, headers, and body:
// POST request with JSON body
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer sk-your-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "Hello" }],
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(data.choices[0].message.content);
Output:
Hello! How can I help you today?
Key insight: Unlike axios, fetch doesn't throw on HTTP error status codes (4xx, 5xx). You must check response.ok or response.status manually. This explicit error handling prevents silent failures in AI applications.
Response Processing
The Response object provides multiple methods for reading the body:
async function processResponse(response: Response): Promise<void> {
// Check response type
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = await response.json();
console.log("JSON:", data);
} else if (contentType.includes("text/")) {
const text = await response.text();
console.log("Text:", text);
} else if (contentType.includes("application/octet-stream")) {
const buffer = await response.arrayBuffer();
console.log("Binary:", buffer.byteLength, "bytes");
}
}
// Example usage
const response = await fetch("https://api.example.com/data");
await processResponse(response);
Output:
JSON: { "data": "example" }
Important: You can only read the body once. Calling response.json() then response.text() on the same response fails. If you need multiple reads, clone the response first: response.clone().
The Request Object
For complex scenarios, construct a Request object explicitly. This separates request configuration from execution:
// Create a reusable request template
function createAPIRequest(endpoint: string, body: unknown): Request {
return new Request(`https://api.openai.com/v1${endpoint}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
"OpenAI-Organization": process.env.OPENAI_ORG_ID ?? "",
},
body: JSON.stringify(body),
});
}
// Use the request
const request = createAPIRequest("/chat/completions", {
model: "gpt-4",
messages: [{ role: "user", content: "Hello" }],
});
console.log("Request URL:", request.url);
console.log("Request method:", request.method);
const response = await fetch(request);
const data = await response.json();
console.log("Response:", data.choices[0].message.content);
Output:
Request URL: https://api.openai.com/v1/chat/completions
Request method: POST
Response: Hello! How can I help you today?
Why use Request objects: They're immutable and inspectable. You can log requests before sending, clone them for retries, and pass them through middleware. This pattern powers SDK request interceptors.
Header Management
The Headers API provides methods for reading, writing, and iterating headers:
// Create headers from object
const headers = new Headers({
"Authorization": "Bearer token",
"Content-Type": "application/json",
});
// Add or modify headers
headers.set("X-Request-ID", crypto.randomUUID());
headers.append("Accept-Language", "en-US");
// Check if header exists
if (headers.has("Authorization")) {
console.log("Auth header present");
}
// Read a header
const contentType = headers.get("Content-Type");
console.log("Content-Type:", contentType);
// Iterate all headers
for (const [name, value] of headers) {
console.log(`${name}: ${value}`);
}
Output:
Auth header present
Content-Type: application/json
authorization: Bearer token
content-type: application/json
x-request-id: 550e8400-e29b-41d4-a716-446655440000
accept-language: en-US
Header name normalization: The Headers API lowercases all header names internally. Authorization and authorization refer to the same header.
Authentication Header Patterns
Different AI providers use different authentication schemes:
interface AuthConfig {
type: "bearer" | "api-key" | "basic";
value: string;
headerName?: string; // For custom header names
}
function createAuthHeaders(config: AuthConfig): Headers {
const headers = new Headers();
switch (config.type) {
case "bearer":
// OpenAI, Anthropic
headers.set("Authorization", `Bearer ${config.value}`);
break;
case "api-key":
// Some providers use X-API-Key
headers.set(config.headerName ?? "X-API-Key", config.value);
break;
case "basic":
// Basic auth (username:password base64 encoded)
const encoded = btoa(config.value);
headers.set("Authorization", `Basic ${encoded}`);
break;
}
return headers;
}
// OpenAI pattern
const openaiHeaders = createAuthHeaders({
type: "bearer",
value: process.env.OPENAI_API_KEY ?? "",
});
// Anthropic pattern
const anthropicHeaders = createAuthHeaders({
type: "api-key",
value: process.env.ANTHROPIC_API_KEY ?? "",
headerName: "x-api-key",
});
console.log("OpenAI:", openaiHeaders.get("Authorization"));
console.log("Anthropic:", anthropicHeaders.get("x-api-key"));
Output:
OpenAI: Bearer sk-...
Anthropic: sk-ant-...
Timeouts with AbortController
Fetch doesn't have a built-in timeout. You implement it with AbortController:
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs: number = 30000
): Promise<Response> {
const controller = new AbortController();
// Set up timeout
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Usage
try {
const response = await fetchWithTimeout(
"https://api.example.com/slow-endpoint",
{ method: "GET" },
5000 // 5 second timeout
);
console.log("Success:", response.status);
} catch (error) {
console.error("Error:", (error as Error).message);
}
Output (when request succeeds quickly):
Success: 200
Output (when request times out):
Error: Request timed out after 5000ms
User-Initiated Cancellation
AbortController also handles user cancellation (e.g., "Stop Generating" button):
interface CancellableRequest<T> {
promise: Promise<T>;
cancel: () => void;
}
function createCancellableRequest<T>(
url: string,
options: RequestInit = {}
): CancellableRequest<T> {
const controller = new AbortController();
const promise = fetch(url, {
...options,
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<T>;
});
return {
promise,
cancel: () => controller.abort(),
};
}
// Usage in a chat UI
interface ChatResponse {
message: string;
}
const request = createCancellableRequest<ChatResponse>(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "Write a long story" }],
}),
}
);
// User clicks "Stop"
// request.cancel();
// Or wait for completion
const response = await request.promise;
console.log("Response:", response.message);
Output:
Response: Once upon a time...
This pattern separates the request lifecycle from the call site, enabling UI integration where a "Stop" button can cancel any in-progress request.
Retry Logic with Exponential Backoff
AI APIs return 429 (Too Many Requests) and 5xx errors that are transient. Retry with exponential backoff handles these gracefully:
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
retryableStatuses: number[];
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
retryableStatuses: [429, 500, 502, 503, 504],
};
async function fetchWithRetry(
url: string,
options: RequestInit = {},
config: Partial<RetryConfig> = {}
): Promise<Response> {
const { maxAttempts, baseDelayMs, maxDelayMs, retryableStatuses } = {
...DEFAULT_RETRY_CONFIG,
...config,
};
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url, options);
// Success or non-retryable error
if (response.ok || !retryableStatuses.includes(response.status)) {
return response;
}
// Retryable error - check retry-after header
const retryAfter = response.headers.get("retry-after");
let delayMs: number;
if (retryAfter) {
// Header can be seconds or HTTP date
const seconds = parseInt(retryAfter, 10);
delayMs = isNaN(seconds)
? Date.parse(retryAfter) - Date.now()
: seconds * 1000;
} else {
// Exponential backoff with jitter
delayMs = Math.min(
baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 1000,
maxDelayMs
);
}
console.log(
`Attempt ${attempt} failed with ${response.status}. ` +
`Retrying in ${Math.round(delayMs)}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw lastError;
}
const delayMs = Math.min(
baseDelayMs * Math.pow(2, attempt - 1),
maxDelayMs
);
console.log(
`Attempt ${attempt} failed with network error. ` +
`Retrying in ${delayMs}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw lastError ?? new Error("Max retries exceeded");
}
// Usage
const response = await fetchWithRetry(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "Hello" }],
}),
},
{ maxAttempts: 3, baseDelayMs: 1000 }
);
console.log("Success:", response.status);
Output (when rate limited then succeeds):
Attempt 1 failed with 429. Retrying in 1523ms...
Attempt 2 failed with 429. Retrying in 3891ms...
Success: 200
Key insight: The retry-after header, when present, tells you exactly how long to wait. Always check it before applying exponential backoff. AI providers like OpenAI include this header with 429 responses.
Building a Cross-Runtime Client
Combine these patterns into a reusable client that works anywhere:
interface ClientConfig {
baseUrl: string;
apiKey: string;
timeout?: number;
retry?: {
maxAttempts?: number;
backoff?: "exponential" | "linear";
};
headers?: Record<string, string>;
}
interface RequestOptions {
method?: string;
headers?: Record<string, string>;
body?: unknown;
timeout?: number;
}
class HTTPClient {
private config: Required<ClientConfig>;
constructor(config: ClientConfig) {
this.config = {
timeout: 30000,
retry: { maxAttempts: 3, backoff: "exponential" },
headers: {},
...config,
};
}
async request<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`;
const timeout = options.timeout ?? this.config.timeout;
// Merge headers
const headers = new Headers({
"Authorization": `Bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
...this.config.headers,
...options.headers,
});
// Build request
const request = new Request(url, {
method: options.method ?? "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
// Execute with timeout and retry
const response = await this.executeWithRetry(request, timeout);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
return response.json() as Promise<T>;
}
private async executeWithRetry(
request: Request,
timeout: number
): Promise<Response> {
const { maxAttempts = 3, backoff = "exponential" } =
this.config.retry ?? {};
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// Clone request for retry (Request body can only be used once)
const req = attempt === 1 ? request : request.clone();
const response = await fetch(req, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
return response;
}
// Check if retryable
if (![429, 500, 502, 503, 504].includes(response.status)) {
return response;
}
if (attempt === maxAttempts) {
return response;
}
// Calculate delay
const baseDelay = 1000;
const delay =
backoff === "exponential"
? baseDelay * Math.pow(2, attempt - 1)
: baseDelay * attempt;
console.log(`Retry ${attempt}/${maxAttempts} after ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
} catch (error) {
clearTimeout(timeoutId);
if ((error as Error).name === "AbortError") {
throw new Error(`Request timed out after ${timeout}ms`);
}
if (attempt === maxAttempts) {
throw error;
}
const delay = 1000 * attempt;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("Max retries exceeded");
}
}
// Usage
const client = new HTTPClient({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY ?? "",
timeout: 30000,
retry: { maxAttempts: 3, backoff: "exponential" },
});
interface ChatCompletion {
id: string;
choices: Array<{
message: { role: string; content: string };
}>;
}
const response = await client.request<ChatCompletion>("/chat/completions", {
method: "POST",
body: {
model: "gpt-4",
messages: [{ role: "user", content: "Hello" }],
},
});
console.log("Response:", response.choices[0].message.content);
Output:
Response: Hello! How can I help you today?
This client:
- Works on Node.js, Deno, Bun, and edge runtimes (uses only standard APIs)
- Handles authentication, timeouts, and retries
- Provides a clean typed interface for API calls
- Becomes the foundation for your SDK in Chapter 76
Comparison with Python Patterns
If you're coming from Python's requests or httpx, here's how patterns translate:
| Python (requests) | TypeScript (fetch) |
|---|---|
requests.get(url) | await fetch(url) |
requests.post(url, json=data) | await fetch(url, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }}) |
response.json() | await response.json() |
response.status_code | response.status |
response.raise_for_status() | if (!response.ok) throw ... |
requests.Session() | new HTTPClient(config) |
timeout=10 | AbortController with setTimeout |
Key difference: Python's requests is synchronous by default; httpx adds async. In TypeScript, fetch is always async. This matches the event-loop model you learned in Lesson 1.
Try With AI
Prompt 1: Building a Multi-Provider Client
I need to call multiple AI providers with the same client interface:
- OpenAI (Bearer token, api.openai.com)
- Anthropic (x-api-key header, api.anthropic.com)
- Google (API key in URL param, generativelanguage.googleapis.com)
Create a cross-runtime HTTPClient that:
1. Supports different authentication patterns per provider
2. Has consistent retry and timeout behavior
3. Returns typed responses
Show me the client class and how to use it with each provider.
What you're learning: How to abstract provider-specific differences into a unified client interface. This pattern is exactly how Vercel AI SDK and LangChain handle multiple AI providers with one API.
Prompt 2: Advanced Retry Strategies
My AI application needs sophisticated retry logic:
1. Rate limit errors (429) should use the retry-after header
2. Server errors (5xx) should use exponential backoff with jitter
3. Network errors should retry immediately once, then back off
4. Some endpoints are idempotent (safe to retry), others aren't
Design a retry system that handles all these cases.
Show me the retry logic and how to mark endpoints as idempotent.
What you're learning: Production-grade retry strategies that prevent cascading failures and handle the nuances of distributed systems. The idempotency consideration is critical for financial or stateful AI operations.
Prompt 3: Request Interceptors and Middleware
I want to add logging and metrics to all my HTTP requests without modifying each call site:
1. Log every request (method, URL, timing)
2. Record latency metrics for monitoring
3. Add a request ID header for tracing
4. Capture error responses for debugging
Design an interceptor pattern for the HTTPClient that allows me to add this functionality without changing application code.
What you're learning: The middleware/interceptor pattern used by production SDKs like Axios and ky. This architecture enables observability, debugging, and cross-cutting concerns without polluting business logic.
Safety note: Never log or expose API keys. When building request logging, sanitize the Authorization header before recording. Use patterns like Bearer sk-...xxxx (showing only the last 4 characters) for debugging without security risk.