mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-10 20:24:57 +03:00
feat: improved the Claude Kit as a plugin
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user