mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-10 20:24:57 +03:00
770 lines
23 KiB
Markdown
770 lines
23 KiB
Markdown
# Api Client — Patterns
|
|
|
|
|
|
# API Client Patterns
|
|
|
|
## When to Use
|
|
|
|
- Setting up HTTP clients (axios, fetch, httpx) for consuming external REST APIs
|
|
- Adding request/response interceptors for logging, auth tokens, or error transformation
|
|
- Implementing retry logic with exponential backoff for transient failures
|
|
- Generating type-safe API clients from OpenAPI or Swagger specifications
|
|
- Managing authentication tokens (Bearer injection, auto-refresh on 401)
|
|
|
|
## When NOT to Use
|
|
|
|
- Internal function calls or in-process service communication that does not cross a network boundary
|
|
- Database queries -- use an ORM or database driver instead
|
|
- WebSocket or real-time streaming connections -- use dedicated WebSocket client patterns
|
|
- GraphQL clients -- use a GraphQL-specific library such as Apollo or urql
|
|
|
|
---
|
|
|
|
## Core Patterns
|
|
|
|
### 1. HTTP Client Setup
|
|
|
|
Create a single, pre-configured client instance per external service. Never scatter raw `fetch()` or `requests.get()` calls throughout the codebase.
|
|
|
|
**Python -- httpx (async)**
|
|
|
|
```python
|
|
# BAD - creating a new client on every call, no shared config
|
|
import httpx
|
|
|
|
async def get_user(user_id: int):
|
|
response = httpx.get(f"https://api.example.com/users/{user_id}") # no timeout, no auth
|
|
return response.json()
|
|
|
|
# GOOD - shared async client with base URL, timeout, and headers
|
|
import httpx
|
|
|
|
class ApiClient:
|
|
def __init__(self, base_url: str, api_key: str):
|
|
self._client = httpx.AsyncClient(
|
|
base_url=base_url,
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Accept": "application/json",
|
|
"User-Agent": "myapp/1.0",
|
|
},
|
|
timeout=httpx.Timeout(10.0, connect=5.0),
|
|
)
|
|
|
|
async def get_user(self, user_id: int) -> dict:
|
|
response = await self._client.get(f"/users/{user_id}")
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
async def close(self):
|
|
await self._client.aclose()
|
|
```
|
|
|
|
**Python -- httpx (sync)**
|
|
|
|
```python
|
|
# GOOD - synchronous client for scripts, CLIs, or sync frameworks
|
|
import httpx
|
|
|
|
client = httpx.Client(
|
|
base_url="https://api.example.com",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
timeout=httpx.Timeout(10.0, connect=5.0),
|
|
)
|
|
|
|
def get_user(user_id: int) -> dict:
|
|
response = client.get(f"/users/{user_id}")
|
|
response.raise_for_status()
|
|
return response.json()
|
|
```
|
|
|
|
**TypeScript -- fetch wrapper**
|
|
|
|
```typescript
|
|
// BAD - raw fetch with no error handling or shared config
|
|
const res = await fetch("https://api.example.com/users/1");
|
|
const data = await res.json();
|
|
|
|
// GOOD - typed fetch wrapper with defaults
|
|
interface RequestConfig extends RequestInit {
|
|
params?: Record<string, string>;
|
|
}
|
|
|
|
class ApiClient {
|
|
constructor(
|
|
private baseUrl: string,
|
|
private defaultHeaders: Record<string, string> = {},
|
|
) {}
|
|
|
|
private async request<T>(path: string, config: RequestConfig = {}): Promise<T> {
|
|
const url = new URL(path, this.baseUrl);
|
|
if (config.params) {
|
|
Object.entries(config.params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
...config,
|
|
headers: { ...this.defaultHeaders, ...config.headers },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, await response.text());
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
get<T>(path: string, config?: RequestConfig): Promise<T> {
|
|
return this.request<T>(path, { ...config, method: "GET" });
|
|
}
|
|
|
|
post<T>(path: string, body: unknown, config?: RequestConfig): Promise<T> {
|
|
return this.request<T>(path, {
|
|
...config,
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
headers: { "Content-Type": "application/json", ...config?.headers },
|
|
});
|
|
}
|
|
}
|
|
|
|
const api = new ApiClient("https://api.example.com", {
|
|
Authorization: `Bearer ${process.env.API_KEY}`,
|
|
Accept: "application/json",
|
|
});
|
|
```
|
|
|
|
**TypeScript -- axios instance**
|
|
|
|
```typescript
|
|
// GOOD - axios instance with shared config
|
|
import axios from "axios";
|
|
|
|
const api = axios.create({
|
|
baseURL: "https://api.example.com",
|
|
timeout: 10_000,
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// All requests share the same base URL, timeout, and headers
|
|
const user = await api.get<User>("/users/1");
|
|
const created = await api.post<User>("/users", { name: "Alice" });
|
|
```
|
|
|
|
### 2. Request/Response Interceptors
|
|
|
|
Interceptors centralize cross-cutting concerns so individual API calls stay clean.
|
|
|
|
**Axios interceptors (TypeScript)**
|
|
|
|
```typescript
|
|
// GOOD - auth token injection
|
|
api.interceptors.request.use((config) => {
|
|
const token = tokenStore.getAccessToken();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// GOOD - request/response logging
|
|
api.interceptors.request.use((config) => {
|
|
console.debug(`[API] ${config.method?.toUpperCase()} ${config.url}`);
|
|
return config;
|
|
});
|
|
|
|
api.interceptors.response.use(
|
|
(response) => {
|
|
console.debug(`[API] ${response.status} ${response.config.url}`);
|
|
return response;
|
|
},
|
|
(error) => {
|
|
console.error(`[API] Error ${error.response?.status} ${error.config?.url}`);
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
// GOOD - error transformation to application-specific errors
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response) {
|
|
const { status, data } = error.response;
|
|
throw new ApiError(status, data?.message ?? "Unknown API error", data?.code);
|
|
}
|
|
if (error.code === "ECONNABORTED") {
|
|
throw new TimeoutError(`Request timed out: ${error.config?.url}`);
|
|
}
|
|
throw new NetworkError("Network connection failed");
|
|
},
|
|
);
|
|
```
|
|
|
|
**httpx event hooks (Python)**
|
|
|
|
```python
|
|
# GOOD - logging and error hooks on httpx client
|
|
import httpx
|
|
import logging
|
|
|
|
logger = logging.getLogger("api_client")
|
|
|
|
async def log_request(request: httpx.Request):
|
|
logger.debug(f"Request: {request.method} {request.url}")
|
|
|
|
async def log_response(response: httpx.Response):
|
|
logger.debug(f"Response: {response.status_code} {response.url}")
|
|
|
|
async def raise_on_error(response: httpx.Response):
|
|
if response.status_code >= 400:
|
|
await response.aread()
|
|
logger.error(f"API error {response.status_code}: {response.text[:200]}")
|
|
|
|
client = httpx.AsyncClient(
|
|
base_url="https://api.example.com",
|
|
event_hooks={
|
|
"request": [log_request],
|
|
"response": [log_response, raise_on_error],
|
|
},
|
|
)
|
|
```
|
|
|
|
### 3. Retry Logic
|
|
|
|
Retry transient failures with exponential backoff. Never retry non-idempotent requests blindly.
|
|
|
|
**Python -- tenacity**
|
|
|
|
```python
|
|
# GOOD - retry with exponential backoff for specific status codes
|
|
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
|
import httpx
|
|
|
|
def is_retryable(exc: BaseException) -> bool:
|
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
return exc.response.status_code in (429, 500, 502, 503, 504)
|
|
if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout)):
|
|
return True
|
|
return False
|
|
|
|
@retry(
|
|
retry=retry_if_exception(is_retryable),
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
reraise=True,
|
|
)
|
|
async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> dict:
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
```
|
|
|
|
**Python -- manual retry with Retry-After**
|
|
|
|
```python
|
|
# GOOD - respects Retry-After header from rate-limited responses
|
|
import asyncio
|
|
import httpx
|
|
|
|
async def fetch_respecting_rate_limit(
|
|
client: httpx.AsyncClient,
|
|
url: str,
|
|
max_retries: int = 3,
|
|
) -> httpx.Response:
|
|
for attempt in range(max_retries):
|
|
response = await client.get(url)
|
|
if response.status_code == 429:
|
|
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
|
|
await asyncio.sleep(min(retry_after, 60))
|
|
continue
|
|
response.raise_for_status()
|
|
return response
|
|
raise httpx.HTTPStatusError(
|
|
"Max retries exceeded", request=response.request, response=response
|
|
)
|
|
```
|
|
|
|
**TypeScript -- custom retry wrapper**
|
|
|
|
```typescript
|
|
// GOOD - generic retry wrapper with exponential backoff
|
|
interface RetryOptions {
|
|
maxRetries: number;
|
|
baseDelay: number;
|
|
maxDelay: number;
|
|
retryableStatuses: number[];
|
|
}
|
|
|
|
const DEFAULT_RETRY: RetryOptions = {
|
|
maxRetries: 3,
|
|
baseDelay: 1000,
|
|
maxDelay: 10000,
|
|
retryableStatuses: [429, 500, 502, 503, 504],
|
|
};
|
|
|
|
async function withRetry<T>(
|
|
fn: () => Promise<T>,
|
|
options: Partial<RetryOptions> = {},
|
|
): Promise<T> {
|
|
const opts = { ...DEFAULT_RETRY, ...options };
|
|
|
|
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
const status = error instanceof ApiError ? error.status : 0;
|
|
const isRetryable = opts.retryableStatuses.includes(status);
|
|
const isLastAttempt = attempt === opts.maxRetries;
|
|
|
|
if (!isRetryable || isLastAttempt) throw error;
|
|
|
|
const delay = Math.min(opts.baseDelay * 2 ** attempt, opts.maxDelay);
|
|
const jitter = delay * (0.5 + Math.random() * 0.5);
|
|
await new Promise((resolve) => setTimeout(resolve, jitter));
|
|
}
|
|
}
|
|
throw new Error("Unreachable");
|
|
}
|
|
|
|
// Usage
|
|
const user = await withRetry(() => api.get<User>("/users/1"));
|
|
```
|
|
|
|
### 4. Type-Safe Clients from OpenAPI
|
|
|
|
Generate clients from OpenAPI specs to eliminate hand-written API types and reduce drift between backend and frontend.
|
|
|
|
**TypeScript -- openapi-typescript + openapi-fetch**
|
|
|
|
```bash
|
|
# Generate types from an OpenAPI spec
|
|
npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts
|
|
```
|
|
|
|
```typescript
|
|
// GOOD - fully typed client from generated schema
|
|
import createClient from "openapi-fetch";
|
|
import type { paths } from "./schema";
|
|
|
|
const api = createClient<paths>({
|
|
baseUrl: "https://api.example.com",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
// Paths, methods, params, and response types are all inferred
|
|
const { data, error } = await api.GET("/users/{id}", {
|
|
params: { path: { id: 42 } },
|
|
});
|
|
// data is typed as the 200 response schema
|
|
// error is typed as the error response schema
|
|
```
|
|
|
|
**TypeScript -- zodios (Zod + axios)**
|
|
|
|
```typescript
|
|
// GOOD - runtime-validated API client with Zod schemas
|
|
import { makeApi, Zodios } from "@zodios/core";
|
|
import { z } from "zod";
|
|
|
|
const userSchema = z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
email: z.string().email(),
|
|
});
|
|
|
|
const api = makeApi([
|
|
{
|
|
method: "get",
|
|
path: "/users/:id",
|
|
alias: "getUser",
|
|
response: userSchema,
|
|
},
|
|
{
|
|
method: "post",
|
|
path: "/users",
|
|
alias: "createUser",
|
|
parameters: [{ name: "body", type: "Body", schema: userSchema.omit({ id: true }) }],
|
|
response: userSchema,
|
|
},
|
|
]);
|
|
|
|
const client = new Zodios("https://api.example.com", api);
|
|
|
|
// Fully typed and runtime validated
|
|
const user = await client.getUser({ params: { id: 42 } });
|
|
```
|
|
|
|
**Python -- datamodel-code-generator**
|
|
|
|
```bash
|
|
# Generate Pydantic models from an OpenAPI spec
|
|
pip install datamodel-code-generator
|
|
datamodel-codegen --input openapi.json --output src/api/models.py --input-file-type openapi
|
|
```
|
|
|
|
```python
|
|
# Generated models are Pydantic BaseModel classes
|
|
from api.models import User, CreateUserRequest
|
|
|
|
# Use them with httpx for typed requests
|
|
async def create_user(client: httpx.AsyncClient, payload: CreateUserRequest) -> User:
|
|
response = await client.post("/users", json=payload.model_dump())
|
|
response.raise_for_status()
|
|
return User.model_validate(response.json())
|
|
```
|
|
|
|
### 5. Authentication
|
|
|
|
Centralize auth token management so every request gets the right credentials without per-call boilerplate.
|
|
|
|
**Bearer token injection (axios)**
|
|
|
|
```typescript
|
|
// GOOD - automatic token refresh on 401
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (error: unknown) => void;
|
|
}> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null) {
|
|
failedQueue.forEach(({ resolve, reject }) => {
|
|
if (error) reject(error);
|
|
else resolve(token!);
|
|
});
|
|
failedQueue = [];
|
|
}
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
|
|
if (error.response?.status !== 401 || originalRequest._retry) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
}).then((token) => {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
return api(originalRequest);
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
const { data } = await axios.post("/auth/refresh", {
|
|
refreshToken: tokenStore.getRefreshToken(),
|
|
});
|
|
tokenStore.setAccessToken(data.accessToken);
|
|
processQueue(null, data.accessToken);
|
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
processQueue(refreshError, null);
|
|
tokenStore.clear();
|
|
window.location.href = "/login";
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
},
|
|
);
|
|
```
|
|
|
|
**Python -- httpx auth class**
|
|
|
|
```python
|
|
# GOOD - custom auth flow with automatic refresh
|
|
import httpx
|
|
import time
|
|
|
|
class BearerAuth(httpx.Auth):
|
|
def __init__(self, token_url: str, client_id: str, client_secret: str):
|
|
self.token_url = token_url
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self._access_token: str | None = None
|
|
self._expires_at: float = 0
|
|
|
|
def auth_flow(self, request: httpx.Request):
|
|
if self._is_expired():
|
|
token_response = yield self._build_token_request()
|
|
token_response.raise_for_status()
|
|
data = token_response.json()
|
|
self._access_token = data["access_token"]
|
|
self._expires_at = time.time() + data["expires_in"] - 30 # 30s buffer
|
|
|
|
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
|
yield request
|
|
|
|
def _is_expired(self) -> bool:
|
|
return self._access_token is None or time.time() >= self._expires_at
|
|
|
|
def _build_token_request(self) -> httpx.Request:
|
|
return httpx.Request(
|
|
"POST",
|
|
self.token_url,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
},
|
|
)
|
|
|
|
# Usage
|
|
auth = BearerAuth(
|
|
token_url="https://auth.example.com/token",
|
|
client_id=os.environ["CLIENT_ID"],
|
|
client_secret=os.environ["CLIENT_SECRET"],
|
|
)
|
|
client = httpx.AsyncClient(base_url="https://api.example.com", auth=auth)
|
|
```
|
|
|
|
**API key via custom header**
|
|
|
|
```python
|
|
# GOOD - API key as header, loaded from environment
|
|
import os
|
|
import httpx
|
|
|
|
client = httpx.AsyncClient(
|
|
base_url="https://api.example.com",
|
|
headers={"X-API-Key": os.environ["EXAMPLE_API_KEY"]},
|
|
)
|
|
```
|
|
|
|
### 6. Error Handling
|
|
|
|
Distinguish between network errors, timeout errors, and API-level errors. Never swallow exceptions silently.
|
|
|
|
**TypeScript -- structured error classes**
|
|
|
|
```typescript
|
|
// GOOD - error hierarchy for API calls
|
|
class ApiError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
public readonly body: string,
|
|
public readonly code?: string,
|
|
) {
|
|
super(`API error ${status}: ${body.slice(0, 200)}`);
|
|
this.name = "ApiError";
|
|
}
|
|
|
|
get isClientError(): boolean {
|
|
return this.status >= 400 && this.status < 500;
|
|
}
|
|
|
|
get isServerError(): boolean {
|
|
return this.status >= 500;
|
|
}
|
|
}
|
|
|
|
class NetworkError extends Error {
|
|
constructor(message: string, public readonly cause?: Error) {
|
|
super(message);
|
|
this.name = "NetworkError";
|
|
}
|
|
}
|
|
|
|
class TimeoutError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "TimeoutError";
|
|
}
|
|
}
|
|
```
|
|
|
|
**Timeout and cancellation with AbortController**
|
|
|
|
```typescript
|
|
// GOOD - cancel requests that take too long or on component unmount
|
|
async function fetchWithTimeout<T>(url: string, timeoutMs: number = 5000): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
if (!response.ok) throw new ApiError(response.status, await response.text());
|
|
return response.json() as Promise<T>;
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
throw new TimeoutError(`Request to ${url} timed out after ${timeoutMs}ms`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
// React -- cancel on unmount
|
|
function useApiData(url: string) {
|
|
const [data, setData] = useState(null);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
fetch(url, { signal: controller.signal })
|
|
.then((res) => res.json())
|
|
.then(setData)
|
|
.catch((err) => {
|
|
if (err.name !== "AbortError") console.error(err);
|
|
});
|
|
return () => controller.abort();
|
|
}, [url]);
|
|
|
|
return data;
|
|
}
|
|
```
|
|
|
|
**Python -- structured error handling**
|
|
|
|
```python
|
|
# GOOD - catch specific httpx exceptions
|
|
import httpx
|
|
|
|
async def safe_api_call(client: httpx.AsyncClient, path: str) -> dict | None:
|
|
try:
|
|
response = await client.get(path)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.ConnectTimeout:
|
|
logger.error(f"Connection timeout: {path}")
|
|
raise
|
|
except httpx.ReadTimeout:
|
|
logger.error(f"Read timeout: {path}")
|
|
raise
|
|
except httpx.HTTPStatusError as exc:
|
|
logger.error(f"HTTP {exc.response.status_code} from {path}: {exc.response.text[:200]}")
|
|
if exc.response.status_code == 404:
|
|
return None
|
|
raise
|
|
except httpx.ConnectError:
|
|
logger.error(f"Connection failed: {path}")
|
|
raise
|
|
```
|
|
|
|
### 7. Rate Limiting (Client-Side)
|
|
|
|
Respect API rate limits to avoid being throttled or banned.
|
|
|
|
**TypeScript -- request queue with concurrency control**
|
|
|
|
```typescript
|
|
// GOOD - throttle outgoing requests to stay under rate limits
|
|
class RequestQueue {
|
|
private queue: Array<() => Promise<void>> = [];
|
|
private running = 0;
|
|
|
|
constructor(
|
|
private maxConcurrent: number = 5,
|
|
private minDelay: number = 100,
|
|
) {}
|
|
|
|
async add<T>(fn: () => Promise<T>): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
this.queue.push(async () => {
|
|
try {
|
|
resolve(await fn());
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
this.process();
|
|
});
|
|
}
|
|
|
|
private async process(): Promise<void> {
|
|
if (this.running >= this.maxConcurrent || this.queue.length === 0) return;
|
|
|
|
this.running++;
|
|
const task = this.queue.shift()!;
|
|
await task();
|
|
await new Promise((resolve) => setTimeout(resolve, this.minDelay));
|
|
this.running--;
|
|
this.process();
|
|
}
|
|
}
|
|
|
|
// Usage -- at most 5 concurrent requests, 100ms between each
|
|
const queue = new RequestQueue(5, 100);
|
|
const users = await Promise.all(
|
|
userIds.map((id) => queue.add(() => api.get<User>(`/users/${id}`))),
|
|
);
|
|
```
|
|
|
|
**Python -- asyncio semaphore throttle**
|
|
|
|
```python
|
|
# GOOD - limit concurrent requests with a semaphore
|
|
import asyncio
|
|
import httpx
|
|
|
|
class ThrottledClient:
|
|
def __init__(self, client: httpx.AsyncClient, max_concurrent: int = 5):
|
|
self._client = client
|
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
|
|
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
async with self._semaphore:
|
|
response = await self._client.get(url, **kwargs)
|
|
# Respect Retry-After if rate limited
|
|
if response.status_code == 429:
|
|
retry_after = int(response.headers.get("Retry-After", "5"))
|
|
await asyncio.sleep(retry_after)
|
|
response = await self._client.get(url, **kwargs)
|
|
return response
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Create one client instance per external service.** Share it across your application. Instantiating new clients on every call wastes connections and prevents connection pooling.
|
|
|
|
2. **Always set explicit timeouts.** A missing timeout means a stuck request can hang your entire application. Set both connect and read timeouts. Five to ten seconds is a sensible default for most APIs.
|
|
|
|
3. **Centralize error handling in interceptors or middleware.** Do not scatter try/catch blocks around every individual API call. Use interceptors to transform HTTP errors into typed application errors.
|
|
|
|
4. **Add jitter to retry backoff.** Pure exponential backoff causes thundering herd problems when many clients retry simultaneously. Add random jitter to spread retries across time.
|
|
|
|
5. **Never retry non-idempotent requests automatically.** POST requests that create resources can cause duplicates if retried blindly. Only retry GET, HEAD, and PUT (idempotent methods) by default.
|
|
|
|
6. **Generate types from OpenAPI specs instead of writing them by hand.** This eliminates drift between backend and frontend types and reduces maintenance effort.
|
|
|
|
7. **Log request and response metadata, not bodies.** Log method, URL, status code, and duration. Avoid logging request or response bodies by default -- they may contain sensitive data like tokens or PII.
|
|
|
|
8. **Close clients when the application shuts down.** In Python, use `async with` or call `aclose()`. In Node.js, use AbortController or connection pool shutdown. Leaked connections cause resource exhaustion.
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
1. **Not closing httpx clients.** Failing to call `aclose()` leaks connections and file descriptors. Use `async with httpx.AsyncClient() as client:` or register a shutdown handler.
|
|
|
|
2. **Storing API keys in source code.** Always load secrets from environment variables or a secret manager. Never commit API keys, tokens, or credentials to version control.
|
|
|
|
3. **Ignoring response status codes.** `fetch()` does not throw on 4xx/5xx -- you must check `response.ok` or call `.raise_for_status()`. This is the most common fetch mistake.
|
|
|
|
4. **Retrying 400-level errors.** Client errors (400, 401, 403, 404, 422) are not transient. Retrying them wastes time and load. Only retry on 429 (rate limit) and 5xx (server errors).
|
|
|
|
5. **Building URLs with string concatenation.** Concatenating user input into URLs creates injection risks and encoding bugs. Use `URL` constructor (JS) or `httpx.URL` (Python) for safe URL building.
|
|
|
|
6. **Not cancelling requests on component unmount.** In React, fetch requests that complete after unmount cause state-update-on-unmounted-component warnings and potential memory leaks. Always use AbortController with a cleanup function.
|
|
|
|
---
|
|
|
|
## Related Skills
|
|
|
|
- `openapi` - OpenAPI spec design and documentation
|
|
- `error-handling` - Structured error handling patterns across the stack
|
|
- `authentication` - Authentication token management and OAuth2 flows
|
|
- `caching` - HTTP caching, conditional requests, and cache invalidation
|
|
- `logging` - Logging HTTP requests and responses
|