mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-22 04:31:00 +03:00
feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
---
|
||||
name: api-client
|
||||
description: >
|
||||
HTTP client patterns for consuming REST APIs in Python and TypeScript. Use this skill when setting up axios, fetch, or httpx clients, implementing request interceptors, adding retry logic, handling authentication tokens, or generating type-safe API clients from OpenAPI specs. Trigger whenever code makes HTTP requests, integrates with external APIs, or needs robust error handling for network calls.
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- `api/openapi` - OpenAPI spec design and documentation
|
||||
- `patterns/error-handling` - Structured error handling patterns across the stack
|
||||
- `patterns/authentication` - Authentication token management and OAuth2 flows
|
||||
- `patterns/caching` - HTTP caching, conditional requests, and cache invalidation
|
||||
- `patterns/logging` - Logging HTTP requests and responses
|
||||
@@ -0,0 +1,250 @@
|
||||
# HTTP Client Patterns Quick Reference
|
||||
|
||||
## Python HTTP Clients
|
||||
|
||||
| Feature | httpx | requests | aiohttp |
|
||||
|---------|-------|----------|---------|
|
||||
| Async support | Yes (native) | No | Yes (async-only) |
|
||||
| HTTP/2 | Yes | No | No |
|
||||
| Connection pooling | Yes | Yes (Session) | Yes |
|
||||
| Streaming | Yes | Yes | Yes |
|
||||
| Type hints | Yes | Partial | Partial |
|
||||
| Timeout default | No timeout | No timeout | 5 min |
|
||||
| Recommended for | Modern projects | Simple scripts | Legacy async |
|
||||
|
||||
### httpx Setup (Recommended)
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Sync client with defaults
|
||||
client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
timeout=httpx.Timeout(10.0, connect=5.0),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
# Async client
|
||||
async_client = httpx.AsyncClient(
|
||||
base_url="https://api.example.com",
|
||||
timeout=10.0,
|
||||
http2=True,
|
||||
)
|
||||
|
||||
# Always use as context manager (ensures cleanup)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("/users")
|
||||
```
|
||||
|
||||
### httpx Retry Pattern
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
||||
)
|
||||
async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> dict:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
### httpx Interceptor Pattern (Event Hooks)
|
||||
|
||||
```python
|
||||
def log_request(request: httpx.Request):
|
||||
print(f"--> {request.method} {request.url}")
|
||||
|
||||
def log_response(response: httpx.Response):
|
||||
print(f"<-- {response.status_code} {response.url} ({response.elapsed.total_seconds():.2f}s)")
|
||||
|
||||
def raise_on_error(response: httpx.Response):
|
||||
response.raise_for_status()
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
event_hooks={
|
||||
"request": [log_request],
|
||||
"response": [log_response, raise_on_error],
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript/TypeScript HTTP Clients
|
||||
|
||||
| Feature | fetch (native) | axios | ky |
|
||||
|---------|---------------|-------|-----|
|
||||
| Built-in | Yes | No (~13KB) | No (~3KB) |
|
||||
| Interceptors | No (manual) | Yes | Yes (hooks) |
|
||||
| Auto JSON | No (manual `.json()`) | Yes | Yes |
|
||||
| Timeout | AbortSignal.timeout() | Built-in | Built-in |
|
||||
| Retry | No | No (plugin) | Built-in |
|
||||
| Cancel | AbortController | CancelToken (deprecated) / AbortController | AbortController |
|
||||
| Streaming | Yes (ReadableStream) | Node only | Yes |
|
||||
| Recommended for | Simple needs, SSR | Large existing codebases | Modern projects |
|
||||
|
||||
### fetch Wrapper Pattern
|
||||
|
||||
```typescript
|
||||
class ApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private defaultHeaders: Record<string, string> = {}
|
||||
) {}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...this.defaultHeaders, ...init?.headers },
|
||||
signal: init?.signal ?? AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new ApiError(response.status, body, url);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
get<T>(path: string, signal?: AbortSignal) {
|
||||
return this.request<T>(path, { signal });
|
||||
}
|
||||
|
||||
post<T>(path: string, data: unknown) {
|
||||
return this.request<T>(path, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
put<T>(path: string, data: unknown) {
|
||||
return this.request<T>(path, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
delete<T>(path: string) {
|
||||
return this.request<T>(path, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ky Setup (Recommended for JS)
|
||||
|
||||
```typescript
|
||||
import ky from "ky";
|
||||
|
||||
const api = ky.create({
|
||||
prefixUrl: "https://api.example.com",
|
||||
timeout: 10_000,
|
||||
retry: { limit: 3, methods: ["get"], statusCodes: [408, 429, 500, 502, 503] },
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
request.headers.set("Authorization", `Bearer ${getToken()}`);
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
if (response.status === 401) {
|
||||
await refreshToken();
|
||||
// ky will retry automatically
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
const users = await api.get("users").json<User[]>();
|
||||
const user = await api.post("users", { json: { name: "Alice" } }).json<User>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Typed Error Class
|
||||
|
||||
```typescript
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: string,
|
||||
public url: string,
|
||||
) {
|
||||
super(`HTTP ${status} from ${url}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get isRetryable(): boolean {
|
||||
return this.status >= 500 || this.status === 429;
|
||||
}
|
||||
|
||||
get isAuthError(): boolean {
|
||||
return this.status === 401;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status: int, body: str, url: str):
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
super().__init__(f"HTTP {status} from {url}")
|
||||
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return self.status >= 500 or self.status == 429
|
||||
```
|
||||
|
||||
### Error Handling Decision
|
||||
|
||||
| Status | Action |
|
||||
|--------|--------|
|
||||
| 400 | Don't retry. Fix the request. Log validation details. |
|
||||
| 401 | Refresh token and retry once. If still 401, re-authenticate. |
|
||||
| 403 | Don't retry. User lacks permission. |
|
||||
| 404 | Don't retry. Resource doesn't exist. |
|
||||
| 408, 429 | Retry with backoff. Respect `Retry-After` header. |
|
||||
| 500-503 | Retry with exponential backoff (max 3 attempts). |
|
||||
| Network error | Retry with backoff. Check connectivity. |
|
||||
| Timeout | Retry with longer timeout or fail fast. |
|
||||
|
||||
---
|
||||
|
||||
## Auth Token Refresh Pattern
|
||||
|
||||
```typescript
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
async function getValidToken(): Promise<string> {
|
||||
const token = getStoredToken();
|
||||
if (!isExpired(token)) return token;
|
||||
|
||||
// Deduplicate concurrent refresh calls
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshToken().finally(() => { refreshPromise = null; });
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Checklist
|
||||
|
||||
| Concern | Implementation |
|
||||
|---------|---------------|
|
||||
| Base URL | Configure once in client factory |
|
||||
| Auth header | Interceptor / hook (not per-request) |
|
||||
| Timeout | Always set (10s default, 30s for uploads) |
|
||||
| Retry | 3 attempts, exponential backoff, only GET + idempotent |
|
||||
| Error handling | Typed errors, status-based decisions |
|
||||
| Cancellation | AbortController (pass signal to all requests) |
|
||||
| Logging | Log method, URL, status, duration (not bodies in prod) |
|
||||
| Content-Type | Set `application/json` as default, override for file uploads |
|
||||
@@ -0,0 +1,859 @@
|
||||
---
|
||||
name: authentication
|
||||
description: >
|
||||
Authentication and authorization patterns for web applications. Use this skill when implementing JWT tokens, OAuth2 flows, session management, role-based access control (RBAC), password hashing, or multi-factor authentication. Trigger whenever code handles login, signup, token refresh, protected routes, permission checks, or user identity verification. Also applies to middleware auth guards and API key authentication.
|
||||
---
|
||||
|
||||
# Authentication & Authorization Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implementing login, signup, or logout flows for web applications
|
||||
- Setting up JWT access tokens and refresh token rotation
|
||||
- Building OAuth2 integrations (Google, GitHub, or custom providers)
|
||||
- Adding role-based or permission-based access control to API endpoints
|
||||
- Protecting routes with middleware guards in Next.js, Express, or FastAPI
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Public-only APIs that require no identity verification (e.g., open data endpoints)
|
||||
- Internal services secured entirely at the network level (VPC, service mesh mTLS) with no application-layer auth
|
||||
- Static sites with no user-specific content or server-side logic
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. JWT Patterns
|
||||
|
||||
Use short-lived access tokens for API authorization and long-lived refresh tokens for session continuity. Never store access tokens in localStorage.
|
||||
|
||||
**Token structure and signing**
|
||||
|
||||
```python
|
||||
# BAD - long-lived token, weak secret, symmetric HS256 with hardcoded key
|
||||
import jwt
|
||||
|
||||
token = jwt.encode(
|
||||
{"user_id": 1, "exp": datetime.utcnow() + timedelta(days=365)},
|
||||
"secret123",
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
# GOOD - short-lived access token, strong secret from env, RS256 for production
|
||||
import jwt
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_EXPIRY = timedelta(days=7)
|
||||
|
||||
def create_access_token(user_id: int, role: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": now + ACCESS_TOKEN_EXPIRY,
|
||||
"type": "access",
|
||||
},
|
||||
os.environ["JWT_PRIVATE_KEY"],
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user_id),
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TOKEN_EXPIRY,
|
||||
"type": "refresh",
|
||||
"jti": str(uuid.uuid4()), # unique ID for revocation
|
||||
},
|
||||
os.environ["JWT_PRIVATE_KEY"],
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(
|
||||
token,
|
||||
os.environ["JWT_PUBLIC_KEY"],
|
||||
algorithms=["RS256"],
|
||||
options={"require": ["sub", "exp", "type"]},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript -- JWT creation and verification**
|
||||
|
||||
```typescript
|
||||
// GOOD - short-lived tokens with jose (works in Node.js and edge runtimes)
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
const privateKey = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
|
||||
async function createAccessToken(userId: string, role: string): Promise<string> {
|
||||
return new SignJWT({ role, type: "access" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("15m")
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function createRefreshToken(userId: string): Promise<string> {
|
||||
return new SignJWT({ type: "refresh", jti: crypto.randomUUID() })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function verifyToken(token: string): Promise<{ sub: string; role?: string }> {
|
||||
const { payload } = await jwtVerify(token, privateKey, {
|
||||
algorithms: ["HS256"],
|
||||
requiredClaims: ["sub", "exp", "type"],
|
||||
});
|
||||
return payload as { sub: string; role?: string };
|
||||
}
|
||||
```
|
||||
|
||||
**Secure cookie delivery**
|
||||
|
||||
```python
|
||||
# GOOD - deliver tokens in httpOnly cookies, not in response body
|
||||
from fastapi import Response
|
||||
|
||||
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
httponly=True, # not accessible via JavaScript
|
||||
secure=True, # HTTPS only
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=int(ACCESS_TOKEN_EXPIRY.total_seconds()),
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=int(REFRESH_TOKEN_EXPIRY.total_seconds()),
|
||||
path="/auth/refresh", # only sent to refresh endpoint
|
||||
)
|
||||
|
||||
def clear_auth_cookies(response: Response):
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("refresh_token", path="/auth/refresh")
|
||||
```
|
||||
|
||||
### 2. OAuth2 Flows
|
||||
|
||||
**Authorization code flow with PKCE (for SPAs and mobile apps)**
|
||||
|
||||
```typescript
|
||||
// GOOD - PKCE flow for single-page applications (no client secret exposed)
|
||||
import crypto from "crypto";
|
||||
|
||||
// Step 1: Generate code verifier and challenge
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(32).toString("base64url");
|
||||
const challenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// Step 2: Redirect user to authorization server
|
||||
function getAuthorizationUrl(codeChallenge: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
|
||||
scope: "openid profile email",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state: crypto.randomBytes(16).toString("hex"),
|
||||
});
|
||||
return `https://auth.example.com/authorize?${params}`;
|
||||
}
|
||||
|
||||
// Step 3: Exchange code for tokens on the callback
|
||||
async function exchangeCode(code: string, codeVerifier: string): Promise<TokenSet> {
|
||||
const response = await fetch("https://auth.example.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
|
||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<TokenSet>;
|
||||
}
|
||||
```
|
||||
|
||||
**Client credentials flow (server-to-server)**
|
||||
|
||||
```python
|
||||
# GOOD - machine-to-machine auth with client credentials
|
||||
import httpx
|
||||
import os
|
||||
|
||||
async def get_service_token() -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://auth.example.com/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": os.environ["SERVICE_CLIENT_ID"],
|
||||
"client_secret": os.environ["SERVICE_CLIENT_SECRET"],
|
||||
"scope": "read:data write:data",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["access_token"]
|
||||
```
|
||||
|
||||
**Python -- OAuth2 callback handling with FastAPI**
|
||||
|
||||
```python
|
||||
# GOOD - secure callback handler with state validation
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
import httpx
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
@router.get("/callback")
|
||||
async def oauth_callback(request: Request, code: str, state: str):
|
||||
# Validate state to prevent CSRF
|
||||
stored_state = request.session.get("oauth_state")
|
||||
if not stored_state or state != stored_state:
|
||||
raise HTTPException(status_code=400, detail="Invalid state parameter")
|
||||
|
||||
code_verifier = request.session.pop("code_verifier")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
"https://auth.example.com/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": os.environ["OAUTH_REDIRECT_URI"],
|
||||
"client_id": os.environ["OAUTH_CLIENT_ID"],
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
tokens = token_response.json()
|
||||
|
||||
# Create local session from OAuth tokens
|
||||
user_info = await fetch_user_info(tokens["access_token"])
|
||||
user = await get_or_create_user(user_info)
|
||||
response = RedirectResponse(url="/dashboard")
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return response
|
||||
```
|
||||
|
||||
### 3. Password Security
|
||||
|
||||
**Python -- argon2 (preferred) and bcrypt**
|
||||
|
||||
```python
|
||||
# BAD - MD5 or SHA-256 alone is trivially crackable
|
||||
import hashlib
|
||||
hashed = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
# GOOD - argon2id (recommended by OWASP)
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
ph = PasswordHasher(
|
||||
time_cost=3, # iterations
|
||||
memory_cost=65536, # 64 MB
|
||||
parallelism=4,
|
||||
)
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return ph.hash(password)
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return ph.verify(hashed, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
# GOOD - bcrypt alternative
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
hashed = bcrypt.using(rounds=12).hash(password)
|
||||
is_valid = bcrypt.verify(password, hashed)
|
||||
```
|
||||
|
||||
**TypeScript -- bcrypt**
|
||||
|
||||
```typescript
|
||||
// GOOD - bcrypt with sufficient cost factor
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
const SALT_ROUNDS = 12; // ~250ms on modern hardware, adjust as needed
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
```
|
||||
|
||||
**Password validation rules**
|
||||
|
||||
```typescript
|
||||
// GOOD - enforce minimum complexity without overly restrictive rules
|
||||
import { z } from "zod";
|
||||
|
||||
const PasswordSchema = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must not exceed 128 characters")
|
||||
.regex(/[a-z]/, "Must contain at least one lowercase letter")
|
||||
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
|
||||
.regex(/[0-9]/, "Must contain at least one digit");
|
||||
|
||||
// Python equivalent with Pydantic
|
||||
from pydantic import BaseModel, field_validator
|
||||
import re
|
||||
|
||||
class PasswordInput(BaseModel):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
if len(v) > 128:
|
||||
raise ValueError("Password must not exceed 128 characters")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Must contain at least one lowercase letter")
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Must contain at least one digit")
|
||||
return v
|
||||
```
|
||||
|
||||
**Timing-safe comparison**
|
||||
|
||||
```python
|
||||
# BAD - standard equality leaks timing information
|
||||
if stored_token == provided_token:
|
||||
grant_access()
|
||||
|
||||
# GOOD - constant-time comparison prevents timing attacks
|
||||
import hmac
|
||||
|
||||
def safe_compare(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
```
|
||||
|
||||
```typescript
|
||||
// GOOD - timing-safe comparison in Node.js
|
||||
import crypto from "crypto";
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Session Management
|
||||
|
||||
**Cookie-based sessions with Redis store (Express)**
|
||||
|
||||
```typescript
|
||||
// GOOD - server-side sessions stored in Redis
|
||||
import session from "express-session";
|
||||
import RedisStore from "connect-redis";
|
||||
import { createClient } from "redis";
|
||||
|
||||
const redisClient = createClient({ url: process.env.REDIS_URL });
|
||||
await redisClient.connect();
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new RedisStore({ client: redisClient, prefix: "sess:" }),
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
},
|
||||
name: "sid", // custom name -- do not use default "connect.sid"
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Session fixation prevention**
|
||||
|
||||
```typescript
|
||||
// GOOD - regenerate session ID after login to prevent fixation
|
||||
app.post("/login", async (req, res) => {
|
||||
const user = await authenticateUser(req.body.email, req.body.password);
|
||||
if (!user) return res.status(401).json({ error: "Invalid credentials" });
|
||||
|
||||
// Regenerate session to prevent fixation attacks
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: "Session error" });
|
||||
req.session.userId = user.id;
|
||||
req.session.role = user.role;
|
||||
req.session.loginAt = Date.now();
|
||||
res.json({ user: { id: user.id, name: user.name } });
|
||||
});
|
||||
});
|
||||
|
||||
// GOOD - clear session fully on logout
|
||||
app.post("/logout", (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ error: "Logout failed" });
|
||||
res.clearCookie("sid");
|
||||
res.json({ message: "Logged out" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Python -- FastAPI session with Redis**
|
||||
|
||||
```python
|
||||
# GOOD - server-side session using Redis
|
||||
from fastapi import Request, Response
|
||||
import redis.asyncio as redis
|
||||
import uuid
|
||||
import json
|
||||
|
||||
redis_client = redis.from_url(os.environ["REDIS_URL"])
|
||||
|
||||
SESSION_TTL = 1800 # 30 minutes
|
||||
|
||||
async def create_session(response: Response, data: dict) -> str:
|
||||
session_id = str(uuid.uuid4())
|
||||
await redis_client.setex(f"session:{session_id}", SESSION_TTL, json.dumps(data))
|
||||
response.set_cookie(
|
||||
key="session_id",
|
||||
value=session_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=SESSION_TTL,
|
||||
)
|
||||
return session_id
|
||||
|
||||
async def get_session(request: Request) -> dict | None:
|
||||
session_id = request.cookies.get("session_id")
|
||||
if not session_id:
|
||||
return None
|
||||
data = await redis_client.get(f"session:{session_id}")
|
||||
if data:
|
||||
# Refresh TTL on access (sliding expiry)
|
||||
await redis_client.expire(f"session:{session_id}", SESSION_TTL)
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
async def destroy_session(request: Request, response: Response):
|
||||
session_id = request.cookies.get("session_id")
|
||||
if session_id:
|
||||
await redis_client.delete(f"session:{session_id}")
|
||||
response.delete_cookie("session_id")
|
||||
```
|
||||
|
||||
### 5. RBAC Patterns
|
||||
|
||||
**Role and permission model**
|
||||
|
||||
```python
|
||||
# GOOD - permission-based RBAC, not just role names
|
||||
from enum import Enum
|
||||
|
||||
class Permission(str, Enum):
|
||||
READ_POSTS = "read:posts"
|
||||
WRITE_POSTS = "write:posts"
|
||||
DELETE_POSTS = "delete:posts"
|
||||
MANAGE_USERS = "manage:users"
|
||||
ADMIN_ALL = "admin:all"
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, set[Permission]] = {
|
||||
"viewer": {Permission.READ_POSTS},
|
||||
"editor": {Permission.READ_POSTS, Permission.WRITE_POSTS},
|
||||
"admin": {Permission.READ_POSTS, Permission.WRITE_POSTS, Permission.DELETE_POSTS, Permission.MANAGE_USERS},
|
||||
"superadmin": {Permission.ADMIN_ALL},
|
||||
}
|
||||
|
||||
def has_permission(user_role: str, required: Permission) -> bool:
|
||||
permissions = ROLE_PERMISSIONS.get(user_role, set())
|
||||
return required in permissions or Permission.ADMIN_ALL in permissions
|
||||
```
|
||||
|
||||
**FastAPI -- dependency-based authorization**
|
||||
|
||||
```python
|
||||
# GOOD - reusable auth dependency with permission check
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
async def get_current_user(request: Request) -> User:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user = await user_repo.get(int(payload["sub"]))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
def require_permission(permission: Permission):
|
||||
async def checker(user: User = Depends(get_current_user)):
|
||||
if not has_permission(user.role, permission):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
return checker
|
||||
|
||||
@app.delete("/posts/{post_id}")
|
||||
async def delete_post(
|
||||
post_id: int,
|
||||
user: User = Depends(require_permission(Permission.DELETE_POSTS)),
|
||||
):
|
||||
post = await post_repo.get(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404)
|
||||
await post_repo.delete(post_id)
|
||||
return {"deleted": True}
|
||||
```
|
||||
|
||||
**Express -- middleware-based authorization**
|
||||
|
||||
```typescript
|
||||
// GOOD - composable permission middleware
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
function requirePermission(...required: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hasAll = required.every(
|
||||
(perm) => user.permissions.includes(perm) || user.permissions.includes("admin:all"),
|
||||
);
|
||||
|
||||
if (!hasAll) {
|
||||
return res.status(403).json({ error: "Insufficient permissions" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.delete("/posts/:id", requirePermission("delete:posts"), deletePostHandler);
|
||||
app.get("/admin/users", requirePermission("manage:users"), listUsersHandler);
|
||||
```
|
||||
|
||||
### 6. Protected Routes
|
||||
|
||||
**Next.js middleware (App Router)**
|
||||
|
||||
```typescript
|
||||
// middleware.ts -- runs on every matching request at the edge
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
const PUBLIC_PATHS = ["/", "/login", "/signup", "/api/auth"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Allow public paths
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = request.cookies.get("access_token")?.value;
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
await jwtVerify(token, secret);
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Token invalid or expired -- redirect to login
|
||||
const response = NextResponse.redirect(new URL("/login", request.url));
|
||||
response.cookies.delete("access_token");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
**FastAPI -- dependency injection guard**
|
||||
|
||||
```python
|
||||
# GOOD - protect entire router with a dependency
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
protected_router = APIRouter(
|
||||
prefix="/api/v1",
|
||||
dependencies=[Depends(get_current_user)], # all routes require auth
|
||||
)
|
||||
|
||||
@protected_router.get("/profile")
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
return {"id": user.id, "name": user.name, "role": user.role}
|
||||
|
||||
@protected_router.get("/admin/stats")
|
||||
async def admin_stats(user: User = Depends(require_permission(Permission.ADMIN_ALL))):
|
||||
return await compute_stats()
|
||||
|
||||
# Mount protected and public routers separately
|
||||
app.include_router(auth_router) # /auth/* -- public
|
||||
app.include_router(protected_router) # /api/v1/* -- requires auth
|
||||
```
|
||||
|
||||
**Express -- route-level guard**
|
||||
|
||||
```typescript
|
||||
// GOOD - auth middleware applied selectively
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const token = req.cookies.access_token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
req.user = payload as AuthUser;
|
||||
next();
|
||||
} catch {
|
||||
res.clearCookie("access_token");
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
||||
// Public routes
|
||||
app.post("/auth/login", loginHandler);
|
||||
app.post("/auth/register", registerHandler);
|
||||
|
||||
// Protected routes
|
||||
app.use("/api", requireAuth);
|
||||
app.get("/api/profile", profileHandler);
|
||||
app.get("/api/posts", listPostsHandler);
|
||||
```
|
||||
|
||||
### 7. Multi-Factor Authentication (TOTP)
|
||||
|
||||
**Python -- pyotp**
|
||||
|
||||
```python
|
||||
# GOOD - TOTP setup and verification
|
||||
import pyotp
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
"""Generate a new TOTP secret for a user."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
def get_totp_provisioning_uri(secret: str, user_email: str, issuer: str = "MyApp") -> str:
|
||||
"""Generate a QR code URI for authenticator app setup."""
|
||||
return pyotp.totp.TOTP(secret).provisioning_uri(
|
||||
name=user_email,
|
||||
issuer_name=issuer,
|
||||
)
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP code with a 30-second window tolerance."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1) # allows +/- 30 seconds
|
||||
```
|
||||
|
||||
**TypeScript -- otplib**
|
||||
|
||||
```typescript
|
||||
// GOOD - TOTP with otplib
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
function getTotpUri(secret: string, email: string): string {
|
||||
return authenticator.keyuri(email, "MyApp", secret);
|
||||
}
|
||||
|
||||
function verifyTotp(secret: string, code: string): boolean {
|
||||
return authenticator.check(code, secret);
|
||||
}
|
||||
```
|
||||
|
||||
**Backup codes**
|
||||
|
||||
```python
|
||||
# GOOD - generate one-time backup codes for MFA recovery
|
||||
import secrets
|
||||
|
||||
def generate_backup_codes(count: int = 10) -> list[str]:
|
||||
"""Generate single-use backup codes. Store hashed, show once."""
|
||||
return [secrets.token_hex(4).upper() for _ in range(count)]
|
||||
# Example output: ["A1B2C3D4", "E5F6A7B8", ...]
|
||||
|
||||
# Store hashed backup codes in the database
|
||||
from argon2 import PasswordHasher
|
||||
ph = PasswordHasher()
|
||||
|
||||
async def store_backup_codes(user_id: int, codes: list[str]):
|
||||
hashed_codes = [ph.hash(code) for code in codes]
|
||||
await db.execute(
|
||||
"UPDATE users SET backup_codes = $1 WHERE id = $2",
|
||||
[json.dumps(hashed_codes), user_id],
|
||||
)
|
||||
|
||||
async def verify_backup_code(user_id: int, code: str) -> bool:
|
||||
user = await db.get(User, user_id)
|
||||
hashed_codes = json.loads(user.backup_codes)
|
||||
for i, hashed in enumerate(hashed_codes):
|
||||
try:
|
||||
if ph.verify(hashed, code):
|
||||
# Remove used code (single-use)
|
||||
hashed_codes.pop(i)
|
||||
await db.execute(
|
||||
"UPDATE users SET backup_codes = $1 WHERE id = $2",
|
||||
[json.dumps(hashed_codes), user_id],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
```
|
||||
|
||||
**MFA login flow**
|
||||
|
||||
```python
|
||||
# GOOD - two-step login: credentials first, then MFA
|
||||
@router.post("/auth/login")
|
||||
async def login(credentials: LoginRequest, response: Response):
|
||||
user = await authenticate_user(credentials.email, credentials.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if user.mfa_enabled:
|
||||
# Issue a short-lived MFA challenge token (not a full session)
|
||||
mfa_token = create_mfa_challenge_token(user.id)
|
||||
return {"requires_mfa": True, "mfa_token": mfa_token}
|
||||
|
||||
# No MFA -- issue full tokens
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return {"user": {"id": user.id, "name": user.name}}
|
||||
|
||||
@router.post("/auth/mfa/verify")
|
||||
async def verify_mfa(payload: MfaVerifyRequest, response: Response):
|
||||
# Validate the MFA challenge token
|
||||
challenge = decode_mfa_challenge_token(payload.mfa_token)
|
||||
user = await user_repo.get(challenge["user_id"])
|
||||
|
||||
if not verify_totp(user.totp_secret, payload.code):
|
||||
# Also check backup codes as fallback
|
||||
if not await verify_backup_code(user.id, payload.code):
|
||||
raise HTTPException(status_code=401, detail="Invalid MFA code")
|
||||
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return {"user": {"id": user.id, "name": user.name}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use short-lived access tokens (5-15 minutes) paired with refresh tokens (7-30 days).** Short access tokens limit the damage window if a token is compromised. Refresh tokens allow seamless re-authentication without re-entering credentials.
|
||||
|
||||
2. **Deliver tokens in httpOnly, secure, sameSite cookies.** Never return tokens in JSON response bodies for browser-based apps. httpOnly prevents XSS from reading the token, secure ensures HTTPS-only transmission, and sameSite=lax mitigates CSRF.
|
||||
|
||||
3. **Hash passwords with argon2id or bcrypt, never with MD5, SHA-1, or SHA-256 alone.** Adaptive hashing functions include a work factor that makes brute-force attacks computationally expensive. Increase the cost factor as hardware improves.
|
||||
|
||||
4. **Regenerate session IDs after login.** Session fixation attacks exploit predictable or reused session IDs. Always issue a new session ID after successful authentication.
|
||||
|
||||
5. **Validate the state parameter in OAuth2 callbacks.** The state parameter prevents CSRF attacks during the authorization flow. Generate a cryptographically random value, store it in the session, and verify it when the callback arrives.
|
||||
|
||||
6. **Implement token revocation for refresh tokens.** Store refresh token JTIs (unique identifiers) in a database or Redis. On logout, revoke all active refresh tokens for the user. Check the revocation list on every refresh attempt.
|
||||
|
||||
7. **Apply the principle of least privilege in RBAC.** Default new users to the most restrictive role. Grant permissions explicitly, not implicitly. Check permissions at the object level, not just the role level.
|
||||
|
||||
8. **Rate-limit authentication endpoints aggressively.** Apply strict rate limits (5-10 attempts per minute) on login, registration, password reset, and MFA verification endpoints. Use both IP-based and account-based limiting.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Storing JWTs in localStorage.** Any XSS vulnerability can steal the token. Use httpOnly cookies instead. If you must use localStorage (e.g., for native apps), pair it with strict CSP and regular XSS auditing.
|
||||
|
||||
2. **Not validating the token type claim.** Without a `type` field in the JWT payload, a refresh token could be used as an access token and vice versa. Always include and verify a `type` claim.
|
||||
|
||||
3. **Using symmetric keys (HS256) with shared secrets across services.** If multiple services verify tokens, any service that can verify can also forge tokens. Use asymmetric keys (RS256/ES256) so only the auth service holds the private key.
|
||||
|
||||
4. **Checking authentication but not authorization.** A valid token proves identity but not permission. Always verify that the authenticated user has the specific permission required for the requested action.
|
||||
|
||||
5. **Returning different error messages for "user not found" vs "wrong password."** This leaks information about which accounts exist (user enumeration). Return a generic "Invalid credentials" message for both cases.
|
||||
|
||||
6. **Not setting absolute session timeouts.** Sliding expiry alone means a session can live forever with continuous activity. Set an absolute maximum lifetime (e.g., 8 hours) in addition to idle timeout (e.g., 30 minutes).
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Access tokens expire within 15 minutes, refresh tokens within 7-30 days
|
||||
- [ ] Tokens delivered in httpOnly, secure, sameSite cookies (not localStorage)
|
||||
- [ ] Passwords hashed with argon2id or bcrypt (12+ rounds)
|
||||
- [ ] Session IDs regenerated after successful login
|
||||
- [ ] OAuth2 state parameter validated on callback
|
||||
- [ ] Refresh tokens have unique JTI and can be revoked
|
||||
- [ ] RBAC permissions checked at object level, not just role level
|
||||
- [ ] Login, registration, and password reset endpoints are rate-limited
|
||||
- [ ] Error messages do not distinguish between "user not found" and "wrong password"
|
||||
- [ ] MFA backup codes are hashed and single-use
|
||||
- [ ] TOTP secrets are stored encrypted at rest
|
||||
- [ ] Absolute session timeout enforced alongside sliding expiry
|
||||
- [ ] CSRF protection in place for all state-changing endpoints
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `security/owasp` - OWASP Top 10 security patterns and secure coding practices
|
||||
- `patterns/api-client` - HTTP client patterns including auth token injection and refresh flows
|
||||
- `frameworks/fastapi` - FastAPI-specific dependency injection and middleware patterns
|
||||
- `frameworks/nextjs` - Next.js middleware and route protection patterns
|
||||
@@ -0,0 +1,237 @@
|
||||
# Authentication Flows Quick Reference
|
||||
|
||||
## Decision Tree: Which Auth Method?
|
||||
|
||||
```
|
||||
What are you building?
|
||||
│
|
||||
├─ Server-rendered web app (Next.js, Django, Rails)?
|
||||
│ └─> SESSION-BASED AUTH
|
||||
│ - HttpOnly cookies, server-side session store
|
||||
│ - Simple, secure, well-understood
|
||||
│
|
||||
├─ SPA + API backend (same domain)?
|
||||
│ └─> SESSION-BASED AUTH (still preferred)
|
||||
│ - Cookies sent automatically, no JS token handling
|
||||
│ - Or: JWT in HttpOnly cookie (not localStorage)
|
||||
│
|
||||
├─ SPA + API backend (different domain)?
|
||||
│ └─> JWT with access + refresh tokens
|
||||
│ - Access token: short-lived, in memory
|
||||
│ - Refresh token: HttpOnly cookie
|
||||
│
|
||||
├─ Mobile app?
|
||||
│ └─> JWT with access + refresh tokens
|
||||
│ - Store refresh token in secure storage (Keychain/Keystore)
|
||||
│ - Access token in memory
|
||||
│
|
||||
├─ Third-party API access?
|
||||
│ └─> OAuth2 + API keys
|
||||
│ - OAuth2 for user-delegated access
|
||||
│ - API keys for server-to-server
|
||||
│
|
||||
├─ Machine-to-machine (service-to-service)?
|
||||
│ └─> API KEYS or OAuth2 Client Credentials
|
||||
│ - API key: simple, rotate regularly
|
||||
│ - Client Credentials: when you need scoped access
|
||||
│
|
||||
└─ CLI tool?
|
||||
└─> OAuth2 Device Code Flow or API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Access + Refresh Token Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │ │ Auth │ │ API │
|
||||
│ (SPA/ │ │ Server │ │ Server │
|
||||
│ Mobile) │ │ │ │ │
|
||||
└────┬──────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ 1. POST /auth/login │ │
|
||||
│ { email, password } │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. 200 OK │ │
|
||||
│ { access_token (15min) } │ │
|
||||
│ Set-Cookie: refresh_token │ │
|
||||
│ (HttpOnly, Secure, 7d) │ │
|
||||
│<─────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 3. GET /api/data │ │
|
||||
│ Authorization: Bearer <access_token> │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 4. 200 OK { data } │ │
|
||||
│<─────────────────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ ── access_token expires ── │ │
|
||||
│ │ │
|
||||
│ 5. GET /api/data │ │
|
||||
│ Authorization: Bearer <expired_token> │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 6. 401 { code: "token_expired" } │
|
||||
│<─────────────────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ 7. POST /auth/refresh │ │
|
||||
│ Cookie: refresh_token │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 8. 200 { new access_token } │ │
|
||||
│ Set-Cookie: new refresh │ │
|
||||
│<─────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 9. Retry original request with new access_token │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
```
|
||||
|
||||
### JWT Best Practices
|
||||
|
||||
| Concern | Recommendation |
|
||||
|---------|---------------|
|
||||
| Access token lifetime | 5-15 minutes |
|
||||
| Refresh token lifetime | 7-30 days |
|
||||
| Access token storage | Memory only (JS variable) |
|
||||
| Refresh token storage | HttpOnly Secure cookie (web) or Keychain (mobile) |
|
||||
| Token rotation | Issue new refresh token on each refresh |
|
||||
| Revocation | Maintain server-side deny list for refresh tokens |
|
||||
| Algorithm | RS256 (asymmetric) for distributed systems, HS256 for single server |
|
||||
| Claims | Minimal: sub, exp, iat, roles/permissions |
|
||||
|
||||
---
|
||||
|
||||
## OAuth2 Authorization Code + PKCE Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │ │ Auth │ │ Resource │
|
||||
│ (Browser)│ │ Provider│ │ Server │
|
||||
└────┬──────┘ └────┬─────┘ └────┬──────┘
|
||||
│ │ │
|
||||
│ 1. Generate: │ │
|
||||
│ code_verifier (random 43-128 chars) │
|
||||
│ code_challenge = SHA256(code_verifier) │
|
||||
│ │ │
|
||||
│ 2. Redirect to: │ │
|
||||
│ /authorize? │ │
|
||||
│ response_type=code │
|
||||
│ client_id=xxx │ │
|
||||
│ redirect_uri=https://app/callback │
|
||||
│ scope=openid profile │
|
||||
│ state=random_csrf_token │
|
||||
│ code_challenge=xxx │
|
||||
│ code_challenge_method=S256 │
|
||||
│────────────────────>│ │
|
||||
│ │ │
|
||||
│ 3. User logs in │ │
|
||||
│ and consents │ │
|
||||
│ │ │
|
||||
│ 4. Redirect to: │ │
|
||||
│ /callback?code=AUTH_CODE&state=xxx │
|
||||
│<────────────────────│ │
|
||||
│ │ │
|
||||
│ 5. Verify state matches │
|
||||
│ │ │
|
||||
│ 6. POST /token │ │
|
||||
│ { grant_type=authorization_code, │
|
||||
│ code=AUTH_CODE, │ │
|
||||
│ redirect_uri=...,│ │
|
||||
│ code_verifier=ORIGINAL_VERIFIER } │
|
||||
│────────────────────>│ │
|
||||
│ │ │
|
||||
│ 7. { access_token, │ │
|
||||
│ refresh_token,│ │
|
||||
│ id_token } │ │
|
||||
│<────────────────────│ │
|
||||
│ │ │
|
||||
│ 8. GET /api/resource │
|
||||
│ Authorization: Bearer <access_token> │
|
||||
│───────────────────────────────────────────>│
|
||||
```
|
||||
|
||||
### PKCE Key Points
|
||||
|
||||
| Term | Purpose |
|
||||
|------|---------|
|
||||
| `code_verifier` | Random string (43-128 chars), stored client-side |
|
||||
| `code_challenge` | `BASE64URL(SHA256(code_verifier))` sent in auth request |
|
||||
| `state` | CSRF protection (random, verify on callback) |
|
||||
| PKCE purpose | Prevents auth code interception (no client secret needed) |
|
||||
|
||||
---
|
||||
|
||||
## Session-Based Auth Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ Browser │ │ Server │
|
||||
│ │ │ (session store) │
|
||||
└────┬──────┘ └────┬──────────────┘
|
||||
│ │
|
||||
│ 1. POST /login │
|
||||
│ { email, password } │
|
||||
│─────────────────────────────>│
|
||||
│ │ 2. Verify credentials
|
||||
│ │ 3. Create session in store
|
||||
│ │ (Redis/DB/memory)
|
||||
│ 4. Set-Cookie: │
|
||||
│ session_id=abc123; │
|
||||
│ HttpOnly; Secure; │
|
||||
│ SameSite=Lax; Path=/ │
|
||||
│<─────────────────────────────│
|
||||
│ │
|
||||
│ 5. GET /dashboard │
|
||||
│ Cookie: session_id=abc123 │ (sent automatically)
|
||||
│─────────────────────────────>│
|
||||
│ │ 6. Lookup session abc123
|
||||
│ │ 7. Attach user to request
|
||||
│ 8. 200 OK │
|
||||
│<─────────────────────────────│
|
||||
│ │
|
||||
│ 9. POST /logout │
|
||||
│─────────────────────────────>│
|
||||
│ │ 10. Delete session from store
|
||||
│ 11. Clear cookie │
|
||||
│<─────────────────────────────│
|
||||
```
|
||||
|
||||
### Session Cookie Settings
|
||||
|
||||
| Attribute | Value | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `HttpOnly` | Always | Prevent JS access (XSS protection) |
|
||||
| `Secure` | Always in prod | Only send over HTTPS |
|
||||
| `SameSite` | `Lax` (default) | CSRF protection (allows top-level navigation) |
|
||||
| `SameSite` | `Strict` | Stronger CSRF (breaks external link login) |
|
||||
| `Path` | `/` | Cookie scope |
|
||||
| `Max-Age` | 86400-2592000 | Session duration (1-30 days) |
|
||||
| `Domain` | Omit or explicit | Cookie scope to domain |
|
||||
|
||||
---
|
||||
|
||||
## Comparison: JWT vs Sessions vs API Keys
|
||||
|
||||
| Aspect | JWT | Sessions | API Keys |
|
||||
|--------|-----|----------|----------|
|
||||
| Stateless | Yes (no server lookup) | No (server-side store) | No (server-side lookup) |
|
||||
| Revocation | Hard (needs deny list) | Easy (delete session) | Easy (delete key) |
|
||||
| Scaling | Easy (no shared state) | Needs shared session store | Needs shared key store |
|
||||
| Security | Token theft = access until expiry | Session theft = access until revoked | Key theft = access until rotated |
|
||||
| Best for | Distributed APIs, mobile | Web apps, SSR | Service-to-service, CLI |
|
||||
| CSRF risk | Low (if not in cookie) | Needs CSRF tokens | N/A (header-based) |
|
||||
| XSS risk | High if in localStorage | Low (HttpOnly cookie) | Low (server-side only) |
|
||||
|
||||
### API Key Best Practices
|
||||
|
||||
| Practice | Details |
|
||||
|----------|---------|
|
||||
| Prefix keys | `sk_live_`, `pk_test_` (identify type/env) |
|
||||
| Hash before storing | Store `SHA256(key)`, never plaintext |
|
||||
| Scope keys | Limit permissions per key |
|
||||
| Set expiry | Auto-expire, require rotation |
|
||||
| Rate limit per key | Prevent abuse |
|
||||
| Transmit in header | `Authorization: Bearer <key>` or `X-API-Key: <key>` |
|
||||
| Never in URL | Query params end up in logs and browser history |
|
||||
@@ -0,0 +1,786 @@
|
||||
---
|
||||
name: caching
|
||||
description: >
|
||||
Caching patterns for web applications, APIs, and data layers. Use this skill when implementing memoization, HTTP cache headers, Redis caching, CDN configuration, or in-memory caches. Trigger whenever code deals with Cache-Control headers, ETags, functools.lru_cache, React useMemo, TanStack Query cache, or any caching strategy. Also applies to cache invalidation, TTL policies, and cache-aside patterns.
|
||||
---
|
||||
|
||||
# Caching
|
||||
|
||||
## When to Use
|
||||
|
||||
- Adding memoization to expensive pure functions or computations
|
||||
- Setting HTTP cache headers on API responses or static assets
|
||||
- Implementing a Redis or in-memory cache layer for database queries
|
||||
- Configuring CDN caching rules for edge distribution
|
||||
- Designing cache invalidation strategies for data that changes
|
||||
- Optimizing Next.js data fetching with built-in caching primitives
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Data that must always be real-time and consistent (financial transactions, inventory counts during checkout) — caching introduces staleness
|
||||
- Write-heavy workloads where invalidation cost exceeds the read savings
|
||||
- Small datasets that are fast to compute or fetch — the overhead of cache management is not worth it
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Memoization
|
||||
|
||||
Memoization caches the result of a function call based on its arguments. Use it for pure functions (same input always produces same output) that are called repeatedly with the same arguments.
|
||||
|
||||
#### Python — functools
|
||||
|
||||
```python
|
||||
from functools import lru_cache, cache
|
||||
|
||||
# lru_cache with a max size — evicts least recently used entries
|
||||
@lru_cache(maxsize=256)
|
||||
def compute_shipping_cost(weight_kg: float, zone: str) -> float:
|
||||
"""Expensive calculation based on weight and shipping zone."""
|
||||
# Complex rate lookup, distance calculation, surcharges...
|
||||
return base_rate * weight_factor * zone_multiplier
|
||||
|
||||
|
||||
# cache (Python 3.9+) — unbounded, equivalent to lru_cache(maxsize=None)
|
||||
@cache
|
||||
def parse_config(config_path: str) -> dict:
|
||||
"""Parse a config file. Result never changes for the same path."""
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# Check cache statistics
|
||||
print(compute_shipping_cost.cache_info())
|
||||
# CacheInfo(hits=142, misses=23, maxsize=256, currsize=23)
|
||||
|
||||
# Clear cache when needed
|
||||
compute_shipping_cost.cache_clear()
|
||||
```
|
||||
|
||||
**Important:** `lru_cache` requires hashable arguments. It does not work with lists, dicts, or mutable objects. For async functions, use `asyncache` or `aiocache`:
|
||||
|
||||
```python
|
||||
# Async memoization with aiocache
|
||||
from aiocache import cached, Cache
|
||||
|
||||
@cached(
|
||||
ttl=300, # 5 minutes
|
||||
cache=Cache.MEMORY,
|
||||
key_builder=lambda f, *args, **kwargs: f"user_profile:{args[0]}",
|
||||
)
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await user_repo.get_with_preferences(user_id)
|
||||
```
|
||||
|
||||
#### React — useMemo and useCallback
|
||||
|
||||
```tsx
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
interface OrderSummaryProps {
|
||||
items: OrderItem[];
|
||||
taxRate: number;
|
||||
onCheckout: (total: number) => void;
|
||||
}
|
||||
|
||||
function OrderSummary({ items, taxRate, onCheckout }: OrderSummaryProps) {
|
||||
// Memoize expensive computation — recalculates only when items or taxRate change
|
||||
const totals = useMemo(() => {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
return { subtotal, tax, total };
|
||||
}, [items, taxRate]);
|
||||
|
||||
// Memoize callback to avoid re-renders in child components
|
||||
const handleCheckout = useCallback(() => {
|
||||
onCheckout(totals.total);
|
||||
}, [onCheckout, totals.total]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Subtotal: ${totals.subtotal.toFixed(2)}</p>
|
||||
<p>Tax: ${totals.tax.toFixed(2)}</p>
|
||||
<p>Total: ${totals.total.toFixed(2)}</p>
|
||||
<CheckoutButton onClick={handleCheckout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When NOT to memoize in React:** Do not wrap every value in `useMemo`. If the computation is trivial (simple arithmetic, string concatenation, array access), the overhead of memoization exceeds the cost of recalculation. Memoize only when profiling shows a performance problem or when the value is passed as a prop to a `React.memo` child.
|
||||
|
||||
### 2. HTTP Caching
|
||||
|
||||
HTTP caching lets browsers and CDNs serve responses without hitting your server. Get this right and you can eliminate 80% or more of redundant requests.
|
||||
|
||||
#### Cache-Control headers
|
||||
|
||||
```python
|
||||
# Python — FastAPI response headers
|
||||
from fastapi import Response
|
||||
|
||||
@router.get("/api/v1/products/{product_id}")
|
||||
async def get_product(product_id: int, response: Response):
|
||||
product = await product_service.get(product_id)
|
||||
|
||||
# Public: CDN and browser can cache. max-age: browser TTL. s-maxage: CDN TTL.
|
||||
response.headers["Cache-Control"] = "public, max-age=60, s-maxage=300"
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/api/v1/me/profile")
|
||||
async def get_my_profile(response: Response, user: User = Depends(get_current_user)):
|
||||
# Private: only browser cache, not CDN (contains user-specific data)
|
||||
response.headers["Cache-Control"] = "private, max-age=300"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/api/v1/orders")
|
||||
async def create_order(order: OrderCreate, response: Response):
|
||||
# No cache for mutations
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await order_service.create(order)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — Express response headers
|
||||
app.get("/api/v1/products/:id", async (req, res) => {
|
||||
const product = await productService.get(req.params.id);
|
||||
|
||||
res.set("Cache-Control", "public, max-age=60, s-maxage=300");
|
||||
res.json(product);
|
||||
});
|
||||
|
||||
// stale-while-revalidate: serve stale content while fetching fresh in background
|
||||
app.get("/api/v1/feed", async (req, res) => {
|
||||
const feed = await feedService.getLatest();
|
||||
|
||||
// Browser uses cache for 60s, then serves stale for up to 600s while revalidating
|
||||
res.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=60, s-maxage=300, stale-while-revalidate=600"
|
||||
);
|
||||
res.json(feed);
|
||||
});
|
||||
```
|
||||
|
||||
#### ETag and conditional requests
|
||||
|
||||
```python
|
||||
# Python — ETag-based caching
|
||||
import hashlib
|
||||
from fastapi import Request, Response
|
||||
|
||||
@router.get("/api/v1/catalog")
|
||||
async def get_catalog(request: Request, response: Response):
|
||||
catalog = await catalog_service.get_full()
|
||||
catalog_json = json.dumps(catalog, sort_keys=True)
|
||||
|
||||
# Generate ETag from content hash
|
||||
etag = f'"{hashlib.md5(catalog_json.encode()).hexdigest()}"'
|
||||
|
||||
# Check if client already has this version
|
||||
if_none_match = request.headers.get("If-None-Match")
|
||||
if if_none_match == etag:
|
||||
return Response(status_code=304) # Not Modified
|
||||
|
||||
response.headers["ETag"] = etag
|
||||
response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
|
||||
return catalog
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — ETag middleware
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
app.get("/api/v1/catalog", async (req, res) => {
|
||||
const catalog = await catalogService.getFull();
|
||||
const body = JSON.stringify(catalog);
|
||||
|
||||
const etag = `"${createHash("md5").update(body).digest("hex")}"`;
|
||||
|
||||
if (req.headers["if-none-match"] === etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.set("ETag", etag);
|
||||
res.set("Cache-Control", "public, max-age=0, must-revalidate");
|
||||
res.json(catalog);
|
||||
});
|
||||
```
|
||||
|
||||
#### Cache-Control cheat sheet
|
||||
|
||||
| Directive | Meaning |
|
||||
|-----------|---------|
|
||||
| `public` | Any cache (CDN, browser) may store the response |
|
||||
| `private` | Only the browser may cache (not CDN) |
|
||||
| `no-store` | Do not cache at all |
|
||||
| `no-cache` | Cache but revalidate with server before using |
|
||||
| `max-age=N` | Browser cache TTL in seconds |
|
||||
| `s-maxage=N` | CDN/proxy cache TTL in seconds (overrides max-age for shared caches) |
|
||||
| `must-revalidate` | Once stale, must revalidate before using |
|
||||
| `stale-while-revalidate=N` | Serve stale while fetching fresh in background for N seconds |
|
||||
| `immutable` | Content will never change (use for hashed assets like `app.a1b2c3.js`) |
|
||||
|
||||
### 3. Redis Caching
|
||||
|
||||
Redis is the standard external cache for web applications. It survives process restarts, can be shared across multiple servers, and supports TTL-based expiry natively.
|
||||
|
||||
#### Cache-aside pattern (read-through)
|
||||
|
||||
The most common pattern: check cache first, fetch from source on miss, populate cache for next time.
|
||||
|
||||
```python
|
||||
# Python — redis cache-aside
|
||||
import json
|
||||
from typing import TypeVar, Callable, Awaitable
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class RedisCache:
|
||||
def __init__(self, redis_url: str, default_ttl: int = 300):
|
||||
self.client = redis.from_url(redis_url, decode_responses=True)
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
async def get_or_set(
|
||||
self,
|
||||
key: str,
|
||||
fetch_fn: Callable[[], Awaitable[T]],
|
||||
ttl: int | None = None,
|
||||
) -> T:
|
||||
"""Cache-aside: return cached value or fetch, cache, and return."""
|
||||
cached = await self.client.get(key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# Cache miss — fetch from source
|
||||
value = await fetch_fn()
|
||||
await self.client.set(
|
||||
key,
|
||||
json.dumps(value, default=str),
|
||||
ex=ttl or self.default_ttl,
|
||||
)
|
||||
return value
|
||||
|
||||
async def invalidate(self, key: str) -> None:
|
||||
await self.client.delete(key)
|
||||
|
||||
async def invalidate_pattern(self, pattern: str) -> None:
|
||||
"""Delete all keys matching a pattern (e.g., 'user:42:*')."""
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await self.client.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
await self.client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
|
||||
# Usage
|
||||
cache = RedisCache("redis://localhost:6379/0")
|
||||
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await cache.get_or_set(
|
||||
key=f"user_profile:{user_id}",
|
||||
fetch_fn=lambda: user_repo.get_with_preferences(user_id),
|
||||
ttl=600, # 10 minutes
|
||||
)
|
||||
|
||||
async def update_user_profile(user_id: int, data: UserUpdate) -> UserProfile:
|
||||
profile = await user_repo.update(user_id, data)
|
||||
await cache.invalidate(f"user_profile:{user_id}")
|
||||
return profile
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — ioredis cache-aside
|
||||
import Redis from "ioredis";
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
|
||||
export class RedisCache {
|
||||
constructor(private defaultTtl = 300) {}
|
||||
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cached = await redis.get(key);
|
||||
if (cached !== null) {
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
|
||||
const value = await fetchFn();
|
||||
await redis.set(key, JSON.stringify(value), "EX", ttl ?? this.defaultTtl);
|
||||
return value;
|
||||
}
|
||||
|
||||
async invalidate(key: string): Promise<void> {
|
||||
await redis.del(key);
|
||||
}
|
||||
|
||||
async invalidatePattern(pattern: string): Promise<void> {
|
||||
const stream = redis.scanStream({ match: pattern, count: 100 });
|
||||
const pipeline = redis.pipeline();
|
||||
for await (const keys of stream) {
|
||||
for (const key of keys as string[]) {
|
||||
pipeline.del(key);
|
||||
}
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Write-through pattern
|
||||
|
||||
Update cache and source simultaneously on writes. Guarantees cache is always fresh at the cost of slower writes.
|
||||
|
||||
```python
|
||||
async def update_product(product_id: int, data: ProductUpdate) -> Product:
|
||||
# Update database
|
||||
product = await product_repo.update(product_id, data)
|
||||
|
||||
# Update cache atomically
|
||||
await cache.client.set(
|
||||
f"product:{product_id}",
|
||||
json.dumps(product.model_dump(), default=str),
|
||||
ex=3600,
|
||||
)
|
||||
|
||||
return product
|
||||
```
|
||||
|
||||
#### TTL strategies
|
||||
|
||||
| Data Type | Recommended TTL | Rationale |
|
||||
|-----------|----------------|-----------|
|
||||
| User session data | 15-30 minutes | Balance security with UX |
|
||||
| Product catalog | 5-60 minutes | Changes infrequently, high read volume |
|
||||
| Search results | 1-5 minutes | Acceptable staleness, expensive to compute |
|
||||
| Configuration | 5-15 minutes | Rarely changes, critical path |
|
||||
| Rate limit counters | Match the rate limit window | Must be precise |
|
||||
| Feature flags | 30-60 seconds | Needs to propagate quickly |
|
||||
|
||||
### 4. Application Cache
|
||||
|
||||
For single-server applications or per-process caching where Redis is overkill.
|
||||
|
||||
#### Python — cachetools
|
||||
|
||||
```python
|
||||
from cachetools import TTLCache, LRUCache
|
||||
from cachetools.keys import hashkey
|
||||
import asyncio
|
||||
|
||||
# TTL cache: entries expire after 300 seconds, max 1000 entries
|
||||
user_cache: TTLCache = TTLCache(maxsize=1000, ttl=300)
|
||||
|
||||
# LRU cache: evicts least recently used when full
|
||||
template_cache: LRUCache = LRUCache(maxsize=100)
|
||||
|
||||
# Thread-safe / async-safe wrapper
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
async def get_user_cached(user_id: int) -> User:
|
||||
key = hashkey(user_id)
|
||||
async with _cache_lock:
|
||||
if key in user_cache:
|
||||
return user_cache[key]
|
||||
|
||||
# Fetch outside the lock to avoid holding it during I/O
|
||||
user = await user_repo.get(user_id)
|
||||
|
||||
async with _cache_lock:
|
||||
user_cache[key] = user
|
||||
return user
|
||||
```
|
||||
|
||||
#### TypeScript — node-cache
|
||||
|
||||
```typescript
|
||||
import NodeCache from "node-cache";
|
||||
|
||||
// stdTTL: default TTL in seconds, checkperiod: cleanup interval
|
||||
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
|
||||
|
||||
export async function getUserCached(userId: number): Promise<User> {
|
||||
const cacheKey = `user:${userId}`;
|
||||
const cached = cache.get<User>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const user = await userRepo.findById(userId);
|
||||
cache.set(cacheKey, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
// Listen for eviction events
|
||||
cache.on("expired", (key: string, value: unknown) => {
|
||||
log.debug({ key }, "cache_entry_expired");
|
||||
});
|
||||
|
||||
// Cache statistics
|
||||
const stats = cache.getStats();
|
||||
// { hits: 1523, misses: 89, keys: 234, ksize: 4680, vsize: 156000 }
|
||||
```
|
||||
|
||||
### 5. CDN Caching
|
||||
|
||||
CDN caching moves content to edge servers close to users. Configure it correctly and your origin server handles a fraction of the traffic.
|
||||
|
||||
#### Cloudflare cache rules
|
||||
|
||||
```python
|
||||
# Python — set headers that Cloudflare respects
|
||||
@router.get("/api/v1/public/articles")
|
||||
async def list_articles(response: Response):
|
||||
articles = await article_service.list_published()
|
||||
|
||||
# Cloudflare respects s-maxage for edge cache TTL
|
||||
response.headers["Cache-Control"] = "public, s-maxage=3600, max-age=60"
|
||||
# Vary tells the CDN to cache different versions for different values
|
||||
response.headers["Vary"] = "Accept-Encoding, Accept-Language"
|
||||
return articles
|
||||
|
||||
|
||||
# Hashed static assets — cache forever
|
||||
@router.get("/assets/{filename}")
|
||||
async def serve_asset(filename: str, response: Response):
|
||||
# Filenames contain content hash: app.a3b2c1.js
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return FileResponse(f"static/{filename}")
|
||||
```
|
||||
|
||||
#### Cache purge on content update
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def purge_cdn_cache(urls: list[str]) -> None:
|
||||
"""Purge specific URLs from Cloudflare edge cache."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache",
|
||||
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
|
||||
json={"files": urls},
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — purge after content update
|
||||
export async function purgeCache(urls: string[]): Promise<void> {
|
||||
await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${CF_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ files: urls }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Purge after publishing an article
|
||||
export async function publishArticle(id: string): Promise<void> {
|
||||
await articleRepo.publish(id);
|
||||
await purgeCache([
|
||||
`https://example.com/api/v1/articles/${id}`,
|
||||
`https://example.com/api/v1/articles`, // List endpoint
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
#### Vary header
|
||||
|
||||
The `Vary` header tells the CDN to maintain separate cached versions for different request header values:
|
||||
|
||||
```
|
||||
Vary: Accept-Encoding → separate cache for gzip vs brotli
|
||||
Vary: Accept-Language → separate cache per language
|
||||
Vary: Accept-Encoding, Authorization → DO NOT DO THIS — Authorization varies per user, so nothing is ever cached
|
||||
```
|
||||
|
||||
**Rule:** Never include `Authorization`, `Cookie`, or other high-cardinality headers in `Vary` unless you specifically want per-user caching (which defeats the purpose of a CDN).
|
||||
|
||||
### 6. Cache Invalidation
|
||||
|
||||
The two hardest problems in computer science are cache invalidation, naming things, and off-by-one errors. Here are patterns that make invalidation manageable.
|
||||
|
||||
#### Tag-based invalidation
|
||||
|
||||
Group related cache entries under tags so you can invalidate them together.
|
||||
|
||||
```python
|
||||
class TaggedCache:
|
||||
"""Redis-backed cache with tag-based invalidation."""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self.redis = redis_client
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int, tags: list[str]) -> None:
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.set(key, value, ex=ttl)
|
||||
for tag in tags:
|
||||
pipe.sadd(f"tag:{tag}", key)
|
||||
pipe.expire(f"tag:{tag}", ttl + 60) # Tag lives slightly longer
|
||||
await pipe.execute()
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self.redis.get(key)
|
||||
|
||||
async def invalidate_tag(self, tag: str) -> int:
|
||||
"""Delete all cache entries associated with a tag."""
|
||||
tag_key = f"tag:{tag}"
|
||||
keys = await self.redis.smembers(tag_key)
|
||||
if keys:
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.delete(*keys)
|
||||
pipe.delete(tag_key)
|
||||
results = await pipe.execute()
|
||||
return results[0] # Number of deleted keys
|
||||
return 0
|
||||
|
||||
|
||||
# Usage
|
||||
cache = TaggedCache(redis_client)
|
||||
|
||||
# Cache a product, tagged with its category and brand
|
||||
await cache.set(
|
||||
key=f"product:{product.id}",
|
||||
value=json.dumps(product.dict()),
|
||||
ttl=3600,
|
||||
tags=[f"category:{product.category_id}", f"brand:{product.brand_id}"],
|
||||
)
|
||||
|
||||
# When a category is updated, invalidate all products in that category
|
||||
await cache.invalidate_tag(f"category:{category_id}")
|
||||
```
|
||||
|
||||
#### Event-driven invalidation
|
||||
|
||||
Invalidate caches in response to domain events rather than inline in business logic.
|
||||
|
||||
```python
|
||||
# events.py
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ProductUpdated:
|
||||
product_id: int
|
||||
category_id: int
|
||||
|
||||
@dataclass
|
||||
class CategoryUpdated:
|
||||
category_id: int
|
||||
|
||||
|
||||
# event_handlers.py
|
||||
async def handle_product_updated(event: ProductUpdated) -> None:
|
||||
await cache.invalidate(f"product:{event.product_id}")
|
||||
await cache.invalidate(f"product_list:category:{event.category_id}")
|
||||
|
||||
async def handle_category_updated(event: CategoryUpdated) -> None:
|
||||
await cache.invalidate_tag(f"category:{event.category_id}")
|
||||
```
|
||||
|
||||
#### Versioned keys
|
||||
|
||||
Instead of deleting cache entries, change the key so old entries become unreachable and expire naturally.
|
||||
|
||||
```python
|
||||
async def get_catalog_version() -> int:
|
||||
"""Stored in Redis or database. Increment on catalog changes."""
|
||||
version = await redis.get("catalog:version")
|
||||
return int(version) if version else 1
|
||||
|
||||
async def get_catalog_cached() -> list[Product]:
|
||||
version = await get_catalog_version()
|
||||
key = f"catalog:v{version}"
|
||||
return await cache.get_or_set(key, catalog_service.get_all, ttl=3600)
|
||||
|
||||
async def on_catalog_change() -> None:
|
||||
"""Called when any product is added, updated, or removed."""
|
||||
await redis.incr("catalog:version")
|
||||
# Old version keys expire naturally via TTL — no explicit deletion needed
|
||||
```
|
||||
|
||||
### 7. Next.js Caching
|
||||
|
||||
Next.js has multiple cache layers. Understanding which layer applies to your use case avoids common bugs.
|
||||
|
||||
#### Data cache with fetch
|
||||
|
||||
```typescript
|
||||
// Cached by default in Next.js App Router (builds use static generation)
|
||||
async function getProducts(): Promise<Product[]> {
|
||||
const res = await fetch("https://api.example.com/products", {
|
||||
// Revalidate every 60 seconds (ISR behavior)
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Opt out of caching entirely
|
||||
async function getCurrentUser(): Promise<User> {
|
||||
const res = await fetch("https://api.example.com/me", {
|
||||
cache: "no-store", // Always fetch fresh
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
#### Tag-based revalidation
|
||||
|
||||
```typescript
|
||||
// Fetch with tags for targeted invalidation
|
||||
async function getProduct(id: string): Promise<Product> {
|
||||
const res = await fetch(`https://api.example.com/products/${id}`, {
|
||||
next: {
|
||||
tags: [`product:${id}`, "products"],
|
||||
},
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Server action to revalidate
|
||||
"use server";
|
||||
import { revalidateTag, revalidatePath } from "next/cache";
|
||||
|
||||
export async function updateProduct(id: string, data: ProductUpdate) {
|
||||
await productApi.update(id, data);
|
||||
|
||||
// Revalidate specific product and the list
|
||||
revalidateTag(`product:${id}`);
|
||||
revalidateTag("products");
|
||||
|
||||
// Or revalidate an entire path
|
||||
revalidatePath("/products");
|
||||
}
|
||||
```
|
||||
|
||||
#### unstable_cache for non-fetch data
|
||||
|
||||
```typescript
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// Cache database queries or any async function
|
||||
const getCachedProducts = unstable_cache(
|
||||
async (categoryId: string) => {
|
||||
return await db.product.findMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
},
|
||||
["products-by-category"], // Cache key prefix
|
||||
{
|
||||
revalidate: 300, // 5 minutes
|
||||
tags: ["products"], // For manual invalidation
|
||||
}
|
||||
);
|
||||
|
||||
// Usage in a Server Component
|
||||
export default async function ProductList({ categoryId }: Props) {
|
||||
const products = await getCachedProducts(categoryId);
|
||||
return <ProductGrid products={products} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Next.js cache layers summary
|
||||
|
||||
| Layer | What It Caches | Controlled By |
|
||||
|-------|---------------|---------------|
|
||||
| Request Memoization | Duplicate fetch calls in a single render | Automatic (same URL + options) |
|
||||
| Data Cache | fetch responses on the server | `next: { revalidate }`, `cache: "no-store"` |
|
||||
| Full Route Cache | Complete HTML and RSC payload | Static vs dynamic rendering |
|
||||
| Router Cache | RSC payload in the browser | `revalidatePath`, `revalidateTag`, `router.refresh()` |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Cache at the right layer** — choose the caching layer closest to the consumer. HTTP caching eliminates network hops. CDN caching eliminates origin hits. Application caching eliminates database queries. Memoization eliminates repeated computation. Layer them, do not pick just one.
|
||||
|
||||
2. **Set TTLs based on data characteristics** — how stale can this data be before it causes a user-visible problem? Set TTL to that tolerance. A product description can be stale for minutes. An account balance cannot be stale at all.
|
||||
|
||||
3. **Use cache-aside as the default pattern** — read from cache, on miss fetch from source, populate cache. It is simple, handles cache failures gracefully (just hits the source), and keeps business logic decoupled from cache logic.
|
||||
|
||||
4. **Always set a max size or TTL** — unbounded caches cause memory leaks. Every cache should have either a size limit with eviction (LRU, LFU) or a TTL, or both. Monitor memory usage.
|
||||
|
||||
5. **Monitor cache hit rates** — a cache with a low hit rate is wasting memory without improving performance. Track hits, misses, and evictions. If the hit rate is below 80%, reconsider your key design or TTL.
|
||||
|
||||
6. **Design cache keys carefully** — keys should be deterministic, unique, and human-readable for debugging. Include a prefix identifying the data type, the relevant IDs, and optionally a version: `product:42:v3`, `user:123:profile`.
|
||||
|
||||
7. **Handle cache failures gracefully** — if Redis is down, fall through to the database. A cache failure should degrade performance, not break the application. Wrap cache calls in try/catch and log failures.
|
||||
|
||||
8. **Warm critical caches on startup** — for data that is expensive to fetch and always needed (configuration, feature flags, popular items), pre-populate the cache at application startup rather than waiting for the first user request to trigger a cold miss.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Cache stampede (thundering herd)** — when a popular cache entry expires, hundreds of requests simultaneously hit the database to regenerate it. Mitigate with lock-based repopulation (only one request fetches, others wait), stale-while-revalidate (serve expired data while one request refreshes), or randomized TTLs (spread expiry times across a range).
|
||||
|
||||
```python
|
||||
# Python — lock-based stampede prevention
|
||||
async def get_with_lock(key: str, fetch_fn, ttl: int = 300) -> Any:
|
||||
cached = await redis.get(key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
lock_key = f"lock:{key}"
|
||||
acquired = await redis.set(lock_key, "1", ex=30, nx=True)
|
||||
|
||||
if acquired:
|
||||
try:
|
||||
value = await fetch_fn()
|
||||
await redis.set(key, json.dumps(value, default=str), ex=ttl)
|
||||
return value
|
||||
finally:
|
||||
await redis.delete(lock_key)
|
||||
else:
|
||||
# Another request is fetching — wait and retry
|
||||
await asyncio.sleep(0.1)
|
||||
return await get_with_lock(key, fetch_fn, ttl)
|
||||
```
|
||||
|
||||
2. **Stale data in production** — a user updates their profile but keeps seeing old data because the cache was not invalidated. Always invalidate or update the cache in every write path. Use write-through caching for critical data, and test invalidation logic as carefully as you test the write itself.
|
||||
|
||||
3. **Caching errors** — if a database query fails and you cache the error response, every subsequent request gets the error until the TTL expires. Only cache successful results. Check the response before writing to cache.
|
||||
|
||||
```python
|
||||
# Wrong — caches None on failure
|
||||
result = await fetch_fn()
|
||||
await redis.set(key, json.dumps(result), ex=ttl)
|
||||
|
||||
# Right — only cache valid results
|
||||
result = await fetch_fn()
|
||||
if result is not None:
|
||||
await redis.set(key, json.dumps(result), ex=ttl)
|
||||
```
|
||||
|
||||
4. **Over-memoizing in React** — wrapping every variable in `useMemo` and every function in `useCallback` adds overhead without benefit unless the value is passed to a memoized child component or is genuinely expensive to compute. Profile first, memoize second.
|
||||
|
||||
5. **Forgetting the Vary header** — if your API returns different content based on `Accept-Language` or `Accept-Encoding` but does not include `Vary`, the CDN may serve the wrong cached version to users. Always set `Vary` when response content depends on request headers.
|
||||
|
||||
6. **Cache key collisions** — using overly generic keys like `"products"` instead of `"products:category:5:page:2:sort:price"` causes different requests to share cached data. Include all parameters that affect the response in the cache key.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/state-management` — Client-side state management patterns that interact with cache layers
|
||||
- `databases/postgresql` — Query optimization and connection pooling that complement caching strategies
|
||||
- `databases/mongodb` — MongoDB query patterns and when to add a cache layer
|
||||
- `frameworks/nextjs` — Next.js data fetching, ISR, and caching architecture
|
||||
- `patterns/api-client` — Client-side caching for API responses
|
||||
@@ -0,0 +1,201 @@
|
||||
# Caching Decision Tree
|
||||
|
||||
## Primary Decision Tree
|
||||
|
||||
```
|
||||
What are you caching?
|
||||
│
|
||||
├─ PURE FUNCTION RESULT (same input = same output)
|
||||
│ │
|
||||
│ ├─ In React component?
|
||||
│ │ └─ useMemo(() => compute(data), [data])
|
||||
│ │
|
||||
│ ├─ Expensive computation called repeatedly?
|
||||
│ │ └─ Memoize the function
|
||||
│ │ Python: @functools.lru_cache or @functools.cache
|
||||
│ │ JS: hand-rolled Map cache or lodash.memoize
|
||||
│ │
|
||||
│ └─ Shared across requests/processes?
|
||||
│ └─ Use external cache (Redis) -- see below
|
||||
│
|
||||
├─ HTTP RESPONSE (browser or CDN caching)
|
||||
│ │
|
||||
│ ├─ Is it public (same for all users)?
|
||||
│ │ │
|
||||
│ │ ├─ Static asset (JS, CSS, images)?
|
||||
│ │ │ └─ Cache-Control: public, max-age=31536000, immutable
|
||||
│ │ │ (Use content hash in filename for busting)
|
||||
│ │ │
|
||||
│ │ ├─ API response that changes occasionally?
|
||||
│ │ │ └─ Cache-Control: public, max-age=60, stale-while-revalidate=300
|
||||
│ │ │ + ETag or Last-Modified for conditional requests
|
||||
│ │ │
|
||||
│ │ └─ HTML page?
|
||||
│ │ └─ Cache-Control: public, max-age=0, must-revalidate
|
||||
│ │ + ETag (let CDN/browser validate freshness)
|
||||
│ │
|
||||
│ └─ Is it private (user-specific)?
|
||||
│ └─ Cache-Control: private, max-age=60
|
||||
│ (Never cache auth tokens or sensitive data at CDN)
|
||||
│
|
||||
├─ DATABASE QUERY RESULT (shared across requests)
|
||||
│ │
|
||||
│ ├─ Read-heavy, rarely changes?
|
||||
│ │ └─ Redis/Memcached with TTL
|
||||
│ │ Pattern: Cache-aside (read-through)
|
||||
│ │
|
||||
│ ├─ Must always be fresh?
|
||||
│ │ └─ Don't cache. Optimize the query instead.
|
||||
│ │ (Add indexes, denormalize, materialized view)
|
||||
│ │
|
||||
│ └─ Needs real-time invalidation?
|
||||
│ └─ Write-through cache or event-driven invalidation
|
||||
│ (Update cache when DB changes)
|
||||
│
|
||||
├─ EXTERNAL API RESPONSE
|
||||
│ │
|
||||
│ ├─ API has rate limits?
|
||||
│ │ └─ Cache aggressively. Respect Cache-Control from API.
|
||||
│ │ Fallback: cache with reasonable TTL (5-60 min)
|
||||
│ │
|
||||
│ ├─ API is slow (>500ms)?
|
||||
│ │ └─ Cache + stale-while-revalidate pattern
|
||||
│ │ Serve stale, refresh in background
|
||||
│ │
|
||||
│ └─ API data is critical and must be fresh?
|
||||
│ └─ Short TTL (10-30s) + circuit breaker on failure
|
||||
│
|
||||
└─ EDGE/CDN CACHING
|
||||
│
|
||||
├─ Global audience, same content?
|
||||
│ └─ CDN with long TTL + purge on deploy
|
||||
│ (Cloudflare, CloudFront, Vercel Edge)
|
||||
│
|
||||
├─ Personalized at edge?
|
||||
│ └─ Edge compute (Cloudflare Workers, Vercel Edge Functions)
|
||||
│ Cache shared parts, inject personalization
|
||||
│
|
||||
└─ A/B testing at edge?
|
||||
└─ Vary by cookie or header
|
||||
Vary: Cookie (careful: reduces cache hit rate)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache-Aside Pattern (Most Common)
|
||||
|
||||
```
|
||||
Read:
|
||||
1. Check cache for key
|
||||
2. HIT --> return cached value
|
||||
3. MISS --> query DB, store in cache with TTL, return value
|
||||
|
||||
Write:
|
||||
1. Update DB
|
||||
2. Delete cache key (don't update -- avoids race conditions)
|
||||
3. Next read will repopulate cache
|
||||
```
|
||||
|
||||
```python
|
||||
# Python + Redis
|
||||
import redis, json
|
||||
|
||||
r = redis.Redis()
|
||||
TTL = 300 # 5 minutes
|
||||
|
||||
def get_user(user_id: str) -> dict:
|
||||
key = f"user:{user_id}"
|
||||
cached = r.get(key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
|
||||
r.setex(key, TTL, json.dumps(user))
|
||||
return user
|
||||
|
||||
def update_user(user_id: str, data: dict):
|
||||
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
|
||||
r.delete(f"user:{user_id}") # Invalidate, don't update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TTL Strategy Guide
|
||||
|
||||
| Data Type | TTL | Rationale |
|
||||
|-----------|-----|-----------|
|
||||
| User session | 15-60 min | Balance security and UX |
|
||||
| User profile | 5-15 min | Changes infrequently |
|
||||
| Product catalog | 1-5 min | Needs reasonable freshness |
|
||||
| Search results | 30s-2 min | Changes frequently |
|
||||
| Static config | 1-24 hours | Rarely changes |
|
||||
| Feature flags | 30s-1 min | Needs fast propagation |
|
||||
| API rate limit counters | Match the rate limit window | Exact timing matters |
|
||||
| Dashboard aggregations | 1-5 min | Expensive to compute |
|
||||
|
||||
### TTL Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|-------------|---------|-----|
|
||||
| No TTL (cache forever) | Stale data, memory leak | Always set a TTL |
|
||||
| TTL too short (<1s) | Cache provides no benefit | Remove cache or increase TTL |
|
||||
| Same TTL for everything | Over/under-caching | Tune per data type |
|
||||
| Stampede on expiry | All caches expire at once, DB overload | Jitter: TTL + random(0, 60s) |
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation Strategies
|
||||
|
||||
| Strategy | How | Best For |
|
||||
|----------|-----|----------|
|
||||
| TTL expiry | Automatic, time-based | Most cases |
|
||||
| Explicit delete | Delete key on write | Strong consistency needs |
|
||||
| Write-through | Update cache on every write | Read-heavy, write-infrequent |
|
||||
| Event-driven | Invalidate on DB change event | Microservices |
|
||||
| Version key | Append version to cache key | Bulk invalidation |
|
||||
| Tag-based | Group keys by tag, purge by tag | CDN, grouped content |
|
||||
|
||||
---
|
||||
|
||||
## Cache Headers Quick Reference
|
||||
|
||||
| Header | Example | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `Cache-Control` | `max-age=3600` | Primary caching directive |
|
||||
| `ETag` | `"abc123"` | Content fingerprint for conditional requests |
|
||||
| `Last-Modified` | `Wed, 29 Jan 2025 12:00:00 GMT` | Timestamp for conditional requests |
|
||||
| `Vary` | `Accept-Encoding, Authorization` | Cache varies by these headers |
|
||||
| `CDN-Cache-Control` | `max-age=86400` | CDN-specific (Cloudflare, etc.) |
|
||||
|
||||
### Common Cache-Control Patterns
|
||||
|
||||
```
|
||||
# Immutable static asset (hashed filename)
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# API data with background refresh
|
||||
Cache-Control: public, max-age=60, stale-while-revalidate=300
|
||||
|
||||
# Private user data
|
||||
Cache-Control: private, no-cache
|
||||
# (no-cache = must revalidate, NOT "don't cache")
|
||||
|
||||
# Never cache
|
||||
Cache-Control: no-store
|
||||
|
||||
# HTML pages (revalidate every time)
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
ETag: "content-hash-here"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When NOT to Cache
|
||||
|
||||
| Scenario | Why |
|
||||
|----------|-----|
|
||||
| Data changes on every request | Cache hit rate ~0% |
|
||||
| Data must be real-time consistent | Stale data is unacceptable |
|
||||
| Write-heavy workload | Constant invalidation negates benefit |
|
||||
| Data is cheap to compute/fetch | Cache overhead exceeds savings |
|
||||
| Sensitive data (PII, financial) | Risk of serving wrong user's data |
|
||||
| Early in development | Premature optimization; adds complexity |
|
||||
@@ -0,0 +1,922 @@
|
||||
---
|
||||
name: error-handling
|
||||
description: >
|
||||
Comprehensive error handling patterns for Python and TypeScript applications. Use this skill whenever writing try/catch blocks, creating custom error classes, implementing retry logic, designing error boundaries in React, building API error responses, or handling failures gracefully. Trigger for any code dealing with exceptions, error propagation, graceful degradation, or fault tolerance.
|
||||
---
|
||||
|
||||
# Error Handling Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building API endpoints that must return consistent error responses
|
||||
- Creating custom exception hierarchies for a domain model
|
||||
- Implementing retry logic for unreliable network calls or external services
|
||||
- Designing React error boundaries for component-level fault isolation
|
||||
- Wrapping third-party libraries that throw unpredictable errors
|
||||
- Converting between error representations at architectural boundaries (e.g., domain errors to HTTP errors)
|
||||
- Adopting the Result pattern to avoid exceptions for expected failure paths
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Simple one-off scripts or throwaway prototypes where unhandled crashes are acceptable
|
||||
- Configuration files, static data, or declarative markup with no runtime logic
|
||||
- Pure data transformation functions where invalid input should be prevented by types, not caught at runtime
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Custom Error Classes
|
||||
|
||||
Define a hierarchy of domain-specific errors so callers can catch at the right granularity.
|
||||
|
||||
**Python**
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""Central registry of machine-readable error codes."""
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
VALIDATION_FAILED = "VALIDATION_FAILED"
|
||||
DUPLICATE_ENTRY = "DUPLICATE_ENTRY"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
RATE_LIMITED = "RATE_LIMITED"
|
||||
EXTERNAL_SERVICE = "EXTERNAL_SERVICE"
|
||||
INTERNAL = "INTERNAL"
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base error for the entire application.
|
||||
|
||||
All domain errors inherit from this so a single except clause
|
||||
can catch everything the application intentionally raises.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.INTERNAL,
|
||||
*,
|
||||
details: dict | None = None,
|
||||
cause: Exception | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
if cause:
|
||||
self.__cause__ = cause
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"error": self.code.value,
|
||||
"message": str(self),
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
def __init__(self, resource: str, identifier: str) -> None:
|
||||
super().__init__(
|
||||
f"{resource} with id '{identifier}' not found",
|
||||
code=ErrorCode.NOT_FOUND,
|
||||
details={"resource": resource, "id": identifier},
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
def __init__(self, field: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Validation failed for '{field}': {reason}",
|
||||
code=ErrorCode.VALIDATION_FAILED,
|
||||
details={"field": field, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class ExternalServiceError(AppError):
|
||||
def __init__(self, service: str, cause: Exception) -> None:
|
||||
super().__init__(
|
||||
f"External service '{service}' failed: {cause}",
|
||||
code=ErrorCode.EXTERNAL_SERVICE,
|
||||
cause=cause,
|
||||
details={"service": service},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
// error-codes.ts
|
||||
export const ErrorCode = {
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
VALIDATION_FAILED: "VALIDATION_FAILED",
|
||||
DUPLICATE_ENTRY: "DUPLICATE_ENTRY",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
RATE_LIMITED: "RATE_LIMITED",
|
||||
EXTERNAL_SERVICE: "EXTERNAL_SERVICE",
|
||||
INTERNAL: "INTERNAL",
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
// app-error.ts
|
||||
export class AppError extends Error {
|
||||
public readonly code: ErrorCode;
|
||||
public readonly details: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ErrorCode = ErrorCode.INTERNAL,
|
||||
details: Record<string, unknown> = {},
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "AppError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} with id '${id}' not found`, ErrorCode.NOT_FOUND, {
|
||||
resource,
|
||||
id,
|
||||
});
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(field: string, reason: string) {
|
||||
super(
|
||||
`Validation failed for '${field}': ${reason}`,
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
{ field, reason }
|
||||
);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ExternalServiceError extends AppError {
|
||||
constructor(service: string, cause: Error) {
|
||||
super(
|
||||
`External service '${service}' failed: ${cause.message}`,
|
||||
ErrorCode.EXTERNAL_SERVICE,
|
||||
{ service },
|
||||
{ cause }
|
||||
);
|
||||
this.name = "ExternalServiceError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Error Boundaries (React)
|
||||
|
||||
Isolate component failures so a single broken widget does not take down the whole page.
|
||||
|
||||
**Using react-error-boundary (recommended)**
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ErrorBoundary,
|
||||
type FallbackProps,
|
||||
} from "react-error-boundary";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
|
||||
<h2 className="font-semibold text-red-800">Something went wrong</h2>
|
||||
<pre className="mt-2 text-sm text-red-700">{error.message}</pre>
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className="mt-3 rounded bg-red-600 px-3 py-1 text-white"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={(error, info) => {
|
||||
// Send to error tracking service
|
||||
reportError({ error, componentStack: info.componentStack });
|
||||
}}
|
||||
onReset={() => {
|
||||
// Clear any stale state before retry
|
||||
queryClient.clear();
|
||||
}}
|
||||
>
|
||||
<Dashboard />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Granular boundaries per feature**
|
||||
|
||||
```typescript
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Each widget fails independently */}
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<RevenueChart />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<UserActivityFeed />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<SystemHealthPanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded border border-dashed border-gray-300 p-6 text-gray-500">
|
||||
<p>This widget failed to load.</p>
|
||||
<button onClick={resetErrorBoundary} className="mt-2 underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Class-based error boundary (when you need full control)**
|
||||
|
||||
```typescript
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ManualErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? <p>Something went wrong.</p>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Retry Patterns
|
||||
|
||||
Retry transient failures with exponential backoff and jitter to avoid thundering herd.
|
||||
|
||||
**Python - Retry decorator with exponential backoff**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import random
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import TypeVar, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry(
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
retryable: tuple[type[Exception], ...] = (Exception,),
|
||||
) -> Callable:
|
||||
"""Retry decorator with exponential backoff and full jitter.
|
||||
|
||||
Args:
|
||||
max_attempts: Total number of attempts including the first call.
|
||||
base_delay: Initial delay in seconds before the first retry.
|
||||
max_delay: Upper bound on the computed delay.
|
||||
retryable: Exception types eligible for retry.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(fn)
|
||||
async def wrapper(*args, **kwargs) -> T:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except retryable as exc:
|
||||
last_exc = exc
|
||||
if attempt == max_attempts:
|
||||
break
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
jitter = random.uniform(0, delay)
|
||||
logger.warning(
|
||||
"Attempt %d/%d failed (%s), retrying in %.2fs",
|
||||
attempt,
|
||||
max_attempts,
|
||||
exc,
|
||||
jitter,
|
||||
)
|
||||
await asyncio.sleep(jitter)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Usage
|
||||
@retry(max_attempts=3, base_delay=0.5, retryable=(ConnectionError, TimeoutError))
|
||||
async def fetch_remote_config(url: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
**TypeScript - Retry wrapper**
|
||||
|
||||
```typescript
|
||||
interface RetryOptions {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
isRetryable?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
baseDelayMs = 1000,
|
||||
maxDelayMs = 30_000,
|
||||
isRetryable = () => true,
|
||||
} = options;
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts || !isRetryable(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const exponential = baseDelayMs * 2 ** (attempt - 1);
|
||||
const capped = Math.min(exponential, maxDelayMs);
|
||||
const jitter = Math.random() * capped;
|
||||
|
||||
console.warn(
|
||||
`Attempt ${attempt}/${maxAttempts} failed, retrying in ${jitter.toFixed(0)}ms`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, jitter));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const data = await withRetry(
|
||||
() => fetch("/api/config").then((r) => r.json()),
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 500,
|
||||
isRetryable: (err) =>
|
||||
err instanceof TypeError || (err as Response)?.status >= 500,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Graceful Degradation
|
||||
|
||||
When a dependency fails, fall back to a degraded but functional state instead of crashing.
|
||||
|
||||
**Circuit breaker pattern (Python)**
|
||||
|
||||
```python
|
||||
import time
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CircuitState(Enum):
|
||||
CLOSED = "closed" # normal operation
|
||||
OPEN = "open" # failing, reject immediately
|
||||
HALF_OPEN = "half_open" # testing recovery
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Prevents cascading failures by short-circuiting calls to an unhealthy dependency."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 5,
|
||||
recovery_timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.failure_threshold = failure_threshold
|
||||
self.recovery_timeout = recovery_timeout
|
||||
self.state = CircuitState.CLOSED
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = 0.0
|
||||
|
||||
def _trip(self) -> None:
|
||||
self.state = CircuitState.OPEN
|
||||
self.last_failure_time = time.monotonic()
|
||||
|
||||
def _reset(self) -> None:
|
||||
self.state = CircuitState.CLOSED
|
||||
self.failure_count = 0
|
||||
|
||||
async def call(self, fn, *args, fallback=None, **kwargs):
|
||||
if self.state == CircuitState.OPEN:
|
||||
if time.monotonic() - self.last_failure_time > self.recovery_timeout:
|
||||
self.state = CircuitState.HALF_OPEN
|
||||
else:
|
||||
if fallback is not None:
|
||||
return fallback() if callable(fallback) else fallback
|
||||
raise ExternalServiceError("circuit-breaker", RuntimeError("Circuit open"))
|
||||
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
if self.state == CircuitState.HALF_OPEN:
|
||||
self._reset()
|
||||
return result
|
||||
except Exception as exc:
|
||||
self.failure_count += 1
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self._trip()
|
||||
raise
|
||||
|
||||
|
||||
# Usage
|
||||
recommendations_circuit = CircuitBreaker(failure_threshold=3, recovery_timeout=60)
|
||||
|
||||
async def get_recommendations(user_id: str) -> list[dict]:
|
||||
return await recommendations_circuit.call(
|
||||
recommendation_service.fetch,
|
||||
user_id,
|
||||
fallback=lambda: [], # empty list when service is down
|
||||
)
|
||||
```
|
||||
|
||||
**Feature-flag degraded mode (TypeScript)**
|
||||
|
||||
```typescript
|
||||
interface FeatureFlags {
|
||||
enableRecommendations: boolean;
|
||||
enableRealTimeUpdates: boolean;
|
||||
enableAdvancedSearch: boolean;
|
||||
}
|
||||
|
||||
const defaultFlags: FeatureFlags = {
|
||||
enableRecommendations: true,
|
||||
enableRealTimeUpdates: true,
|
||||
enableAdvancedSearch: true,
|
||||
};
|
||||
|
||||
async function getFlags(): Promise<FeatureFlags> {
|
||||
try {
|
||||
const resp = await fetch("/api/feature-flags");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
return await resp.json();
|
||||
} catch {
|
||||
// Fall back to safe defaults when flag service is unavailable
|
||||
console.warn("Feature flag service unavailable, using defaults");
|
||||
return defaultFlags;
|
||||
}
|
||||
}
|
||||
|
||||
// Component that degrades gracefully
|
||||
function SearchPage() {
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasicSearch />
|
||||
{flags.enableAdvancedSearch ? (
|
||||
<AdvancedFilters />
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Advanced search is temporarily unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. API Error Responses
|
||||
|
||||
Return consistent, machine-readable error payloads following RFC 7807 Problem Details.
|
||||
|
||||
**Python (FastAPI)**
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.status import (
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_429_TOO_MANY_REQUESTS,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Map domain error codes to HTTP status codes
|
||||
STATUS_MAP: dict[ErrorCode, int] = {
|
||||
ErrorCode.NOT_FOUND: HTTP_404_NOT_FOUND,
|
||||
ErrorCode.VALIDATION_FAILED: HTTP_400_BAD_REQUEST,
|
||||
ErrorCode.DUPLICATE_ENTRY: 409,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.RATE_LIMITED: HTTP_429_TOO_MANY_REQUESTS,
|
||||
ErrorCode.EXTERNAL_SERVICE: 502,
|
||||
ErrorCode.INTERNAL: HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
status = STATUS_MAP.get(exc.code, 500)
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"type": f"https://docs.example.com/errors/{exc.code.value.lower()}",
|
||||
"title": exc.code.value.replace("_", " ").title(),
|
||||
"status": status,
|
||||
"detail": str(exc),
|
||||
**exc.details,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
# Never leak internal details to the client
|
||||
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"type": "https://docs.example.com/errors/internal",
|
||||
"title": "Internal Server Error",
|
||||
"status": 500,
|
||||
"detail": "An unexpected error occurred.",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript (Express middleware)**
|
||||
|
||||
```typescript
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const STATUS_MAP: Record<ErrorCode, number> = {
|
||||
NOT_FOUND: 404,
|
||||
VALIDATION_FAILED: 400,
|
||||
DUPLICATE_ENTRY: 409,
|
||||
UNAUTHORIZED: 401,
|
||||
RATE_LIMITED: 429,
|
||||
EXTERNAL_SERVICE: 502,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
const status = STATUS_MAP[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
type: `https://docs.example.com/errors/${err.code.toLowerCase()}`,
|
||||
title: err.code.replace(/_/g, " ").toLowerCase(),
|
||||
status,
|
||||
detail: err.message,
|
||||
...err.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unhandled errors - log full details, return generic message
|
||||
console.error("Unhandled error:", err);
|
||||
res.status(500).json({
|
||||
type: "https://docs.example.com/errors/internal",
|
||||
title: "Internal Server Error",
|
||||
status: 500,
|
||||
detail: "An unexpected error occurred.",
|
||||
});
|
||||
}
|
||||
|
||||
// Register as the last middleware
|
||||
app.use(errorHandler);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Error Logging Integration
|
||||
|
||||
Attach structured context to errors so they are searchable and actionable in observability tools.
|
||||
|
||||
**Python - Structured error logging**
|
||||
|
||||
```python
|
||||
import logging
|
||||
import traceback
|
||||
from contextvars import ContextVar
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="unknown")
|
||||
|
||||
|
||||
class StructuredErrorLogger:
|
||||
"""Wraps the standard logger to attach error context automatically."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.logger = logging.getLogger(name)
|
||||
|
||||
def error(
|
||||
self,
|
||||
msg: str,
|
||||
*,
|
||||
exc: Exception | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
context = {
|
||||
"request_id": request_id_var.get(),
|
||||
**(extra or {}),
|
||||
}
|
||||
|
||||
if exc is not None:
|
||||
context["error_type"] = type(exc).__name__
|
||||
context["error_message"] = str(exc)
|
||||
context["stacktrace"] = traceback.format_exception(exc)
|
||||
|
||||
if isinstance(exc, AppError):
|
||||
context["error_code"] = exc.code.value
|
||||
context["error_details"] = exc.details
|
||||
|
||||
self.logger.error(msg, extra={"structured": context}, exc_info=exc)
|
||||
|
||||
|
||||
# Usage in a FastAPI middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import uuid
|
||||
|
||||
log = StructuredErrorLogger(__name__)
|
||||
|
||||
|
||||
class ErrorLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
rid = request.headers.get("x-request-id", str(uuid.uuid4()))
|
||||
request_id_var.set(rid)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Request failed",
|
||||
exc=exc,
|
||||
extra={"method": request.method, "path": request.url.path},
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
**TypeScript - Error context enrichment**
|
||||
|
||||
```typescript
|
||||
interface ErrorContext {
|
||||
requestId?: string;
|
||||
userId?: string;
|
||||
operation?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function logError(
|
||||
message: string,
|
||||
error: unknown,
|
||||
context: ErrorContext = {}
|
||||
): void {
|
||||
const payload: Record<string, unknown> = {
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...context,
|
||||
};
|
||||
|
||||
if (error instanceof AppError) {
|
||||
payload.errorCode = error.code;
|
||||
payload.errorMessage = error.message;
|
||||
payload.errorDetails = error.details;
|
||||
} else if (error instanceof Error) {
|
||||
payload.errorType = error.name;
|
||||
payload.errorMessage = error.message;
|
||||
payload.stack = error.stack;
|
||||
} else {
|
||||
payload.errorRaw = String(error);
|
||||
}
|
||||
|
||||
// Structured JSON log for ingestion by Datadog, CloudWatch, etc.
|
||||
console.error(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Usage
|
||||
try {
|
||||
await processOrder(orderId);
|
||||
} catch (error) {
|
||||
logError("Order processing failed", error, {
|
||||
requestId: req.headers["x-request-id"],
|
||||
operation: "processOrder",
|
||||
orderId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Result Pattern
|
||||
|
||||
Use a Result type for operations where failure is an expected outcome. Avoids exception overhead and makes the failure path explicit in the type signature.
|
||||
|
||||
**Python - Using the `result` library**
|
||||
|
||||
```python
|
||||
# pip install result
|
||||
from result import Ok, Err, Result
|
||||
|
||||
|
||||
def parse_age(value: str) -> Result[int, str]:
|
||||
"""Parse a string to a valid age. Returns Err for invalid input."""
|
||||
try:
|
||||
age = int(value)
|
||||
except ValueError:
|
||||
return Err(f"'{value}' is not a number")
|
||||
|
||||
if age < 0 or age > 150:
|
||||
return Err(f"Age {age} is out of valid range (0-150)")
|
||||
|
||||
return Ok(age)
|
||||
|
||||
|
||||
def validate_registration(data: dict) -> Result[dict, list[str]]:
|
||||
"""Validate all fields, collecting every error instead of failing on the first."""
|
||||
errors: list[str] = []
|
||||
|
||||
match parse_age(data.get("age", "")):
|
||||
case Ok(age):
|
||||
data["age"] = age
|
||||
case Err(msg):
|
||||
errors.append(msg)
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
errors.append("Name is required")
|
||||
if len(name) > 100:
|
||||
errors.append("Name must be 100 characters or fewer")
|
||||
|
||||
if errors:
|
||||
return Err(errors)
|
||||
return Ok(data)
|
||||
|
||||
|
||||
# Caller handles both paths explicitly
|
||||
match validate_registration(form_data):
|
||||
case Ok(valid):
|
||||
user = create_user(valid)
|
||||
case Err(errs):
|
||||
return {"errors": errs}, 400
|
||||
```
|
||||
|
||||
**TypeScript - Discriminated union Result**
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
// Usage
|
||||
function parseAge(input: string): Result<number, string> {
|
||||
const age = Number(input);
|
||||
if (Number.isNaN(age)) return err(`'${input}' is not a number`);
|
||||
if (age < 0 || age > 150) return err(`Age ${age} out of range (0-150)`);
|
||||
return ok(age);
|
||||
}
|
||||
|
||||
function validateRegistration(
|
||||
data: Record<string, string>
|
||||
): Result<{ name: string; age: number }, string[]> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const ageResult = parseAge(data.age ?? "");
|
||||
if (!ageResult.ok) errors.push(ageResult.error);
|
||||
|
||||
const name = data.name?.trim() ?? "";
|
||||
if (!name) errors.push("Name is required");
|
||||
if (name.length > 100) errors.push("Name must be 100 characters or fewer");
|
||||
|
||||
if (errors.length > 0) return err(errors);
|
||||
return ok({ name, age: (ageResult as { ok: true; value: number }).value });
|
||||
}
|
||||
|
||||
// Caller
|
||||
const result = validateRegistration(formData);
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ errors: result.error });
|
||||
}
|
||||
const user = await createUser(result.value);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Catch specific exceptions, not bare `except` or `catch`.** A catch-all hides bugs. Catch only the errors you know how to handle and let everything else propagate.
|
||||
|
||||
2. **Translate errors at architectural boundaries.** A database `IntegrityError` should become a domain `DuplicateEntryError` at the repository layer, then an HTTP 409 at the API layer. Each layer speaks its own error language.
|
||||
|
||||
3. **Preserve the original cause.** Always chain the original exception (`raise X from original` in Python, `{ cause }` in TypeScript) so the root cause is visible in logs and debuggers.
|
||||
|
||||
4. **Fail fast, recover high.** Detect errors as early as possible (validate inputs at the boundary) but handle them at the highest level that has enough context to decide what to do (e.g., return an HTTP response, show a fallback UI).
|
||||
|
||||
5. **Never swallow errors silently.** An empty `except: pass` or `catch {}` is almost always a bug. At minimum, log the error. If you intentionally ignore it, leave a comment explaining why.
|
||||
|
||||
6. **Use the Result pattern for expected failures.** When a function can legitimately fail (parsing, validation, lookups), return a Result instead of throwing. Reserve exceptions for truly unexpected situations.
|
||||
|
||||
7. **Make errors actionable.** Every error message should help the reader fix the problem. Include what happened, what was expected, and what the caller can do about it. `"User not found"` is worse than `"User with id '123' not found. Verify the id and check that the user has not been deleted."`.
|
||||
|
||||
8. **Test the error paths.** Write explicit tests for every error branch. Verify the error type, message, and status code. Error paths that are never tested are error paths that will break in production.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Catching too broadly.** Using `except Exception` or `catch (e: any)` silences programming errors like `TypeError` or `ReferenceError` that should crash loudly during development.
|
||||
|
||||
2. **Logging and re-throwing without deduplication.** If every layer logs the same error, you get five log entries for one failure. Log at the outermost handler and let inner layers propagate.
|
||||
|
||||
3. **Returning error data in the wrong shape.** Mixing `{ error: "..." }`, `{ message: "..." }`, and `{ errors: [...] }` across endpoints forces every client to handle multiple formats. Pick one shape and enforce it globally.
|
||||
|
||||
4. **Leaking internal details to clients.** Stack traces, database table names, and file paths in API responses are a security risk. Sanitize errors before they leave the server.
|
||||
|
||||
5. **Retrying non-idempotent operations.** Retrying a `POST /orders` that partially succeeded can create duplicate orders. Only retry operations that are safe to repeat, or use idempotency keys.
|
||||
|
||||
6. **Ignoring async error boundaries.** In React, error boundaries do not catch errors inside event handlers or async callbacks. Use try/catch inside `onClick`, `useEffect` cleanup, and promise chains separately.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/logging` - Structured logging setup and conventions
|
||||
- `patterns/api-client` - HTTP client wrappers with built-in error handling
|
||||
- `security/owasp` - Preventing information leakage through error messages
|
||||
- `languages/python` - Python exception syntax and idioms
|
||||
- `languages/typescript` - TypeScript error types and narrowing
|
||||
@@ -0,0 +1,170 @@
|
||||
# Error Taxonomy Quick Reference
|
||||
|
||||
## Two Fundamental Categories
|
||||
|
||||
### Operational Errors (Expected, Recoverable)
|
||||
|
||||
Errors that occur in correctly written programs due to external conditions.
|
||||
|
||||
**Response**: Handle gracefully. Log, retry, return error to user.
|
||||
|
||||
| Category | Description | HTTP Status | Example |
|
||||
|----------|-------------|-------------|---------|
|
||||
| Validation | Invalid input data | `400` | Missing required field, wrong format |
|
||||
| Authentication | Identity not established | `401` | Missing/expired/invalid token |
|
||||
| Authorization | Insufficient permissions | `403` | User lacks role for this action |
|
||||
| Not Found | Resource does not exist | `404` | Item with given ID not in DB |
|
||||
| Conflict | State conflict | `409` | Duplicate email, concurrent edit |
|
||||
| Rate Limit | Too many requests | `429` | API quota exceeded |
|
||||
| Payload Too Large | Request body too big | `413` | File upload exceeds limit |
|
||||
| Unprocessable | Valid syntax, invalid semantics | `422` | Transfer amount exceeds balance |
|
||||
| External Dependency | Third-party service failed | `502` / `503` | Payment gateway timeout |
|
||||
| Service Unavailable | System overloaded or in maintenance | `503` | DB connection pool exhausted |
|
||||
|
||||
### Programmer Errors (Bugs, Fix the Code)
|
||||
|
||||
Errors caused by mistakes in the code itself.
|
||||
|
||||
**Response**: Fix the code. Do NOT catch and continue. Crash, log, alert.
|
||||
|
||||
| Category | Example | Fix |
|
||||
|----------|---------|-----|
|
||||
| TypeError | Calling method on undefined | Add null check or fix data flow |
|
||||
| ReferenceError | Using undeclared variable | Fix variable name/scope |
|
||||
| Assertion failure | Invariant violated | Fix logic that broke invariant |
|
||||
| Wrong argument type | Passing string where number expected | Fix caller or add validation |
|
||||
| Missing error handling | Unhandled promise rejection | Add try/catch or .catch() |
|
||||
| Off-by-one | Array index out of bounds | Fix loop/index logic |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling by Category
|
||||
|
||||
### Validation Errors (400)
|
||||
|
||||
```python
|
||||
# Python/FastAPI
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
email: str
|
||||
age: int
|
||||
|
||||
@validator("age")
|
||||
def validate_age(cls, v):
|
||||
if v < 0 or v > 150:
|
||||
raise ValueError("Age must be between 0 and 150")
|
||||
return v
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
class ValidationError extends AppError {
|
||||
constructor(public fields: Record<string, string>) {
|
||||
super("Validation failed", 400);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
| Scenario | Response | Action |
|
||||
|----------|----------|--------|
|
||||
| No token provided | 401 + `WWW-Authenticate` header | Client should authenticate |
|
||||
| Token expired | 401 + error code `token_expired` | Client should refresh token |
|
||||
| Token invalid | 401 + error code `invalid_token` | Client should re-authenticate |
|
||||
|
||||
### Not Found (404)
|
||||
|
||||
| Scenario | Use 404? |
|
||||
|----------|----------|
|
||||
| Resource by ID doesn't exist | Yes |
|
||||
| Search returns no results | **No** -- return empty list with 200 |
|
||||
| Resource soft-deleted | Depends on visibility rules |
|
||||
| User lacks access to resource | Consider 403, or 404 to hide existence |
|
||||
|
||||
### Conflict (409)
|
||||
|
||||
| Scenario | Resolution |
|
||||
|----------|------------|
|
||||
| Duplicate unique field | Return which field conflicts |
|
||||
| Optimistic locking failure | Return current version, client retries |
|
||||
| State transition invalid | Return current state and valid transitions |
|
||||
|
||||
### External Dependency (502/503)
|
||||
|
||||
| Strategy | When |
|
||||
|----------|------|
|
||||
| Retry with backoff | Transient failures (timeouts, 503) |
|
||||
| Circuit breaker | Repeated failures from same service |
|
||||
| Fallback / degraded mode | Non-critical dependency |
|
||||
| Queue for later | Async-compatible operations |
|
||||
|
||||
---
|
||||
|
||||
## Error Response Format
|
||||
|
||||
### Standard Error Response (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": [
|
||||
{ "field": "email", "message": "Invalid email format" },
|
||||
{ "field": "age", "message": "Must be a positive number" }
|
||||
],
|
||||
"request_id": "req_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Code Convention
|
||||
|
||||
| Code Pattern | Meaning |
|
||||
|-------------|---------|
|
||||
| `VALIDATION_ERROR` | Input validation failed |
|
||||
| `AUTH_TOKEN_EXPIRED` | Token needs refresh |
|
||||
| `RESOURCE_NOT_FOUND` | Entity doesn't exist |
|
||||
| `RATE_LIMIT_EXCEEDED` | Throttled |
|
||||
| `CONFLICT_DUPLICATE` | Uniqueness violation |
|
||||
| `INTERNAL_ERROR` | Unexpected server error (hide details) |
|
||||
|
||||
---
|
||||
|
||||
## Decision Guide
|
||||
|
||||
```
|
||||
Error occurred
|
||||
|
|
||||
+--> Can the program continue safely?
|
||||
| |
|
||||
| +--> YES (operational error)
|
||||
| | |
|
||||
| | +--> Is it the client's fault? --> 4xx
|
||||
| | +--> Is it our fault? --> 5xx
|
||||
| | +--> Is it a dependency? --> 502/503
|
||||
| |
|
||||
| +--> NO (programmer error)
|
||||
| |
|
||||
| +--> Log full stack trace
|
||||
| +--> Return generic 500 to client
|
||||
| +--> Alert on-call
|
||||
| +--> Fix the code
|
||||
|
|
||||
+--> Should the client see details?
|
||||
|
|
||||
+--> 4xx: Yes, help them fix their request
|
||||
+--> 5xx: No, generic message + request_id for support
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Why It's Bad | Instead |
|
||||
|-------------|-------------|---------|
|
||||
| Catch-all silently | Hides bugs | Catch specific errors, rethrow unknown |
|
||||
| Return 200 with error body | Breaks HTTP semantics | Use proper status codes |
|
||||
| Expose stack traces in prod | Security risk | Log internally, return request_id |
|
||||
| String error matching | Fragile, breaks on message change | Use error codes/classes |
|
||||
| Catch and log only | Request hangs or returns wrong data | Handle or propagate |
|
||||
@@ -0,0 +1,962 @@
|
||||
---
|
||||
name: logging
|
||||
description: >
|
||||
Structured logging patterns for Python and Node.js applications. Use this skill when setting up loggers, choosing log levels, implementing correlation IDs for request tracing, redacting sensitive data from logs, or configuring log aggregation. Trigger whenever code uses console.log, print(), logging module, winston, pino, structlog, or any logging library. Also applies when building observability, debugging production issues, or adding telemetry.
|
||||
---
|
||||
|
||||
# Logging
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up structured logging in a new application or service
|
||||
- Replacing `console.log` or `print()` with proper logging infrastructure
|
||||
- Adding request tracing with correlation IDs across microservices
|
||||
- Redacting sensitive data (passwords, tokens, PII) from log output
|
||||
- Building observability pipelines with log aggregation (ELK, Datadog, CloudWatch)
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Static analysis or linting tasks that do not involve runtime output
|
||||
- Pure computation functions where logging would add unnecessary noise
|
||||
- Test assertions — use testing frameworks' built-in assertion messages, not log output
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Structured Logging Setup
|
||||
|
||||
Structured logging outputs machine-parseable JSON instead of free-form strings. This enables searching, filtering, and alerting in log aggregation systems.
|
||||
|
||||
#### Python with structlog
|
||||
|
||||
```python
|
||||
# logging_config.py
|
||||
import logging
|
||||
import structlog
|
||||
|
||||
def configure_logging(log_level: str = "INFO", json_output: bool = True) -> None:
|
||||
"""Configure structured logging for the application.
|
||||
|
||||
Call this once at application startup, before any loggers are created.
|
||||
"""
|
||||
# Set the stdlib logging level as the baseline filter
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
level=getattr(logging, log_level.upper()),
|
||||
)
|
||||
|
||||
# Choose renderers based on environment
|
||||
if json_output:
|
||||
renderer = structlog.processors.JSONRenderer()
|
||||
else:
|
||||
# Human-readable output for local development
|
||||
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
renderer,
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# Usage anywhere in the application
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def create_user(email: str) -> User:
|
||||
logger.info("creating_user", email=email)
|
||||
user = await user_repo.create(email=email)
|
||||
logger.info("user_created", user_id=user.id, email=email)
|
||||
return user
|
||||
```
|
||||
|
||||
**Output (JSON mode):**
|
||||
```json
|
||||
{"event": "user_created", "user_id": 42, "email": "alice@example.com", "logger": "app.services.user", "level": "info", "timestamp": "2025-06-15T10:30:00.123Z"}
|
||||
```
|
||||
|
||||
#### Node.js with pino
|
||||
|
||||
```typescript
|
||||
// logger.ts
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
// Use pretty printing only in development
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
// Base fields included in every log line
|
||||
base: {
|
||||
service: process.env.SERVICE_NAME ?? "api",
|
||||
version: process.env.APP_VERSION ?? "unknown",
|
||||
},
|
||||
// Customize serialization
|
||||
serializers: {
|
||||
err: pino.stdSerializers.err,
|
||||
req: pino.stdSerializers.req,
|
||||
res: pino.stdSerializers.res,
|
||||
},
|
||||
// Redact sensitive fields (see Pattern 4)
|
||||
redact: ["req.headers.authorization", "req.headers.cookie"],
|
||||
});
|
||||
|
||||
// Create child loggers for specific modules
|
||||
export function createLogger(module: string): pino.Logger {
|
||||
return logger.child({ module });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in a service
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("user-service");
|
||||
|
||||
export async function createUser(email: string): Promise<User> {
|
||||
log.info({ email }, "creating_user");
|
||||
const user = await userRepo.create({ email });
|
||||
log.info({ userId: user.id, email }, "user_created");
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**Output (JSON):**
|
||||
```json
|
||||
{"level":30,"time":1718444400123,"service":"api","module":"user-service","userId":42,"email":"alice@example.com","msg":"user_created"}
|
||||
```
|
||||
|
||||
### 2. Log Levels
|
||||
|
||||
Use log levels consistently to control verbosity and enable filtering in production.
|
||||
|
||||
| Level | When to Use | Example |
|
||||
|-------|-------------|---------|
|
||||
| `DEBUG` | Detailed diagnostic information useful only during development or debugging | Variable values, SQL queries, cache hits/misses |
|
||||
| `INFO` | Confirmation that things are working as expected | Request received, user created, job completed |
|
||||
| `WARNING` | Something unexpected happened but the application can continue | Deprecated API called, retry attempt, approaching rate limit |
|
||||
| `ERROR` | A specific operation failed but the application continues running | Database query failed, external API returned 500, payment declined |
|
||||
| `CRITICAL` | The application cannot continue or is in an unrecoverable state | Database connection pool exhausted, out of disk space, configuration missing |
|
||||
|
||||
#### Python examples
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# DEBUG: Detailed internals for troubleshooting
|
||||
logger.debug("cache_lookup", key="user:42", hit=True, ttl_remaining=120)
|
||||
|
||||
# INFO: Normal business events
|
||||
logger.info("order_placed", order_id="ORD-123", total=99.99, items=3)
|
||||
|
||||
# WARNING: Degraded but functional
|
||||
logger.warning(
|
||||
"rate_limit_approaching",
|
||||
current_rate=450,
|
||||
limit=500,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
# ERROR: Operation failed, needs attention
|
||||
logger.error(
|
||||
"payment_failed",
|
||||
order_id="ORD-123",
|
||||
provider="stripe",
|
||||
error_code="card_declined",
|
||||
exc_info=True, # Include stack trace
|
||||
)
|
||||
|
||||
# CRITICAL: System-level failure
|
||||
logger.critical(
|
||||
"database_pool_exhausted",
|
||||
active_connections=100,
|
||||
max_connections=100,
|
||||
waiting_requests=47,
|
||||
)
|
||||
```
|
||||
|
||||
#### TypeScript examples
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("order-service");
|
||||
|
||||
// DEBUG: Internal details
|
||||
log.debug({ key: "user:42", hit: true, ttlRemaining: 120 }, "cache_lookup");
|
||||
|
||||
// INFO: Normal events
|
||||
log.info({ orderId: "ORD-123", total: 99.99, items: 3 }, "order_placed");
|
||||
|
||||
// WARNING: Degraded state
|
||||
log.warn(
|
||||
{ currentRate: 450, limit: 500, windowSeconds: 60 },
|
||||
"rate_limit_approaching"
|
||||
);
|
||||
|
||||
// ERROR: Operation failure
|
||||
log.error(
|
||||
{ orderId: "ORD-123", provider: "stripe", errorCode: "card_declined" },
|
||||
"payment_failed"
|
||||
);
|
||||
|
||||
// FATAL: Unrecoverable
|
||||
log.fatal(
|
||||
{ activeConnections: 100, maxConnections: 100, waitingRequests: 47 },
|
||||
"database_pool_exhausted"
|
||||
);
|
||||
```
|
||||
|
||||
**Level selection rule of thumb:** If you would page someone at 3 AM, it is ERROR or CRITICAL. If it is useful context for investigating an issue, it is INFO. If it is only useful when actively debugging a specific problem, it is DEBUG.
|
||||
|
||||
### 3. Correlation IDs
|
||||
|
||||
Correlation IDs (also called request IDs or trace IDs) tie together all log entries from a single request as it flows through your system.
|
||||
|
||||
#### Python — FastAPI middleware with contextvars
|
||||
|
||||
```python
|
||||
# middleware/correlation.py
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
# Context variable accessible from any async task in the same request
|
||||
correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="")
|
||||
|
||||
class CorrelationIDMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Accept an incoming correlation ID or generate a new one
|
||||
correlation_id = request.headers.get("X-Correlation-ID", uuid.uuid4().hex)
|
||||
correlation_id_var.set(correlation_id)
|
||||
|
||||
# Bind to structlog context so all logs in this request include it
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
return response
|
||||
```
|
||||
|
||||
```python
|
||||
# Register the middleware
|
||||
from middleware.correlation import CorrelationIDMiddleware
|
||||
|
||||
app.add_middleware(CorrelationIDMiddleware)
|
||||
```
|
||||
|
||||
```python
|
||||
# Any logger call in any module now includes correlation_id automatically
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def get_user(user_id: int) -> User:
|
||||
logger.info("fetching_user", user_id=user_id)
|
||||
# Output: {"event": "fetching_user", "user_id": 42, "correlation_id": "a1b2c3d4...", ...}
|
||||
return await user_repo.get(user_id)
|
||||
```
|
||||
|
||||
#### TypeScript — Express middleware with AsyncLocalStorage
|
||||
|
||||
```typescript
|
||||
// middleware/correlation.ts
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface RequestContext {
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
export const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function correlationMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const correlationId =
|
||||
(req.headers["x-correlation-id"] as string) ?? randomUUID();
|
||||
|
||||
res.setHeader("X-Correlation-ID", correlationId);
|
||||
|
||||
asyncLocalStorage.run({ correlationId }, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// logger.ts — augment the logger to include correlation ID
|
||||
import { asyncLocalStorage } from "./middleware/correlation";
|
||||
|
||||
export function getContextLogger(): pino.Logger {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
if (store) {
|
||||
return logger.child({ correlationId: store.correlationId });
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in any module
|
||||
import { getContextLogger } from "./logger";
|
||||
|
||||
export async function getUser(userId: number): Promise<User> {
|
||||
const log = getContextLogger();
|
||||
log.info({ userId }, "fetching_user");
|
||||
// Output includes correlationId automatically
|
||||
return await userRepo.findById(userId);
|
||||
}
|
||||
```
|
||||
|
||||
#### Propagating to downstream services
|
||||
|
||||
When calling other microservices, forward the correlation ID:
|
||||
|
||||
```python
|
||||
# Python — httpx client
|
||||
import httpx
|
||||
from middleware.correlation import correlation_id_var
|
||||
|
||||
async def call_billing_service(user_id: int) -> dict:
|
||||
correlation_id = correlation_id_var.get()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"http://billing-service/api/v1/invoices?user_id={user_id}",
|
||||
headers={"X-Correlation-ID": correlation_id},
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — fetch with correlation ID
|
||||
import { asyncLocalStorage } from "./middleware/correlation";
|
||||
|
||||
export async function callBillingService(userId: number): Promise<Invoice[]> {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
const response = await fetch(
|
||||
`http://billing-service/api/v1/invoices?user_id=${userId}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Correlation-ID": store?.correlationId ?? "",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Sensitive Data Redaction
|
||||
|
||||
Never log passwords, API keys, tokens, credit card numbers, or personally identifiable information (PII). Build redaction into the logging pipeline so developers cannot accidentally leak secrets.
|
||||
|
||||
#### Python — structlog processor
|
||||
|
||||
```python
|
||||
# processors/redact.py
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Patterns for sensitive field names (case-insensitive matching)
|
||||
SENSITIVE_KEYS = re.compile(
|
||||
r"(password|passwd|secret|token|api_key|apikey|authorization|"
|
||||
r"credit_card|card_number|cvv|ssn|social_security)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern for credit card numbers in string values
|
||||
CREDIT_CARD_PATTERN = re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b")
|
||||
|
||||
# Pattern for bearer tokens in string values
|
||||
BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*", re.IGNORECASE)
|
||||
|
||||
|
||||
def redact_sensitive_data(
|
||||
logger: Any, method_name: str, event_dict: dict
|
||||
) -> dict:
|
||||
"""Structlog processor that masks sensitive values."""
|
||||
return _redact_dict(event_dict)
|
||||
|
||||
|
||||
def _redact_dict(data: dict) -> dict:
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if SENSITIVE_KEYS.search(key):
|
||||
result[key] = "***REDACTED***"
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _redact_dict(value)
|
||||
elif isinstance(value, str):
|
||||
result[key] = _redact_string(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_redact_dict(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _redact_string(value: str) -> str:
|
||||
value = CREDIT_CARD_PATTERN.sub("****-****-****-****", value)
|
||||
value = BEARER_PATTERN.sub("Bearer ***REDACTED***", value)
|
||||
return value
|
||||
```
|
||||
|
||||
```python
|
||||
# Add the processor to structlog configuration
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
redact_sensitive_data, # Add before the renderer
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
# ...
|
||||
)
|
||||
```
|
||||
|
||||
#### TypeScript — pino redaction
|
||||
|
||||
```typescript
|
||||
// pino has built-in redaction support
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: "info",
|
||||
redact: {
|
||||
paths: [
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"apiKey",
|
||||
"authorization",
|
||||
"creditCard",
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"body.password",
|
||||
"body.creditCardNumber",
|
||||
"*.password",
|
||||
"*.secret",
|
||||
],
|
||||
censor: "***REDACTED***",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For more complex redaction (regex-based), use a custom serializer:
|
||||
|
||||
```typescript
|
||||
// redact.ts
|
||||
const CREDIT_CARD_RE = /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g;
|
||||
const BEARER_RE = /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
|
||||
export function redactValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
let result = value.replace(CREDIT_CARD_RE, "****-****-****-****");
|
||||
result = result.replace(BEARER_RE, "Bearer ***REDACTED***");
|
||||
return result;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return redactObject(value as Record<string, unknown>);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEYS =
|
||||
/^(password|passwd|secret|token|api_?key|authorization|credit_?card|cvv|ssn)$/i;
|
||||
|
||||
function redactObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.test(key)) {
|
||||
result[key] = "***REDACTED***";
|
||||
} else {
|
||||
result[key] = redactValue(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Request/Response Logging
|
||||
|
||||
Log every HTTP request and response with method, path, status code, duration, and body size. This is the single most valuable log line for production debugging.
|
||||
|
||||
#### Python — FastAPI middleware
|
||||
|
||||
```python
|
||||
# middleware/request_logging.py
|
||||
import time
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = structlog.get_logger("http")
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Log request
|
||||
logger.info(
|
||||
"http_request_started",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
query=str(request.url.query) or None,
|
||||
client_ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
logger.error(
|
||||
"http_request_failed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
content_length = response.headers.get("content-length")
|
||||
|
||||
# Choose log level based on status code
|
||||
log_method = logger.info
|
||||
if response.status_code >= 500:
|
||||
log_method = logger.error
|
||||
elif response.status_code >= 400:
|
||||
log_method = logger.warning
|
||||
|
||||
log_method(
|
||||
"http_request_completed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
content_length=int(content_length) if content_length else None,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
#### TypeScript — Express middleware
|
||||
|
||||
```typescript
|
||||
// middleware/request-logging.ts
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getContextLogger } from "../logger";
|
||||
|
||||
export function requestLogging(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const start = process.hrtime.bigint();
|
||||
const log = getContextLogger();
|
||||
|
||||
log.info(
|
||||
{
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
clientIp: req.ip,
|
||||
userAgent: req.get("user-agent"),
|
||||
},
|
||||
"http_request_started"
|
||||
);
|
||||
|
||||
// Hook into the response finish event
|
||||
res.on("finish", () => {
|
||||
const durationMs =
|
||||
Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
|
||||
const logFn =
|
||||
res.statusCode >= 500
|
||||
? log.error.bind(log)
|
||||
: res.statusCode >= 400
|
||||
? log.warn.bind(log)
|
||||
: log.info.bind(log);
|
||||
|
||||
logFn(
|
||||
{
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
contentLength: res.get("content-length")
|
||||
? parseInt(res.get("content-length")!, 10)
|
||||
: undefined,
|
||||
},
|
||||
"http_request_completed"
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Register middleware (order matters)
|
||||
app.use(correlationMiddleware);
|
||||
app.use(requestLogging);
|
||||
```
|
||||
|
||||
### 6. Error Logging
|
||||
|
||||
When logging errors, always include the stack trace, relevant context, and enough information to reproduce the issue without accessing the production environment.
|
||||
|
||||
#### Python — exception logging with context
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def process_order(order_id: str) -> Order:
|
||||
logger.info("processing_order", order_id=order_id)
|
||||
|
||||
try:
|
||||
order = await order_repo.get(order_id)
|
||||
if not order:
|
||||
logger.error("order_not_found", order_id=order_id)
|
||||
raise OrderNotFoundError(order_id)
|
||||
|
||||
payment = await payment_service.charge(
|
||||
amount=order.total,
|
||||
currency=order.currency,
|
||||
customer_id=order.customer_id,
|
||||
)
|
||||
logger.info(
|
||||
"payment_processed",
|
||||
order_id=order_id,
|
||||
payment_id=payment.id,
|
||||
amount=order.total,
|
||||
)
|
||||
|
||||
except PaymentError as exc:
|
||||
# Log the error with full context for debugging
|
||||
logger.error(
|
||||
"payment_failed",
|
||||
order_id=order_id,
|
||||
customer_id=order.customer_id,
|
||||
amount=order.total,
|
||||
error_code=exc.code,
|
||||
error_message=str(exc),
|
||||
exc_info=True, # Includes full stack trace
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
# Catch-all for unexpected errors
|
||||
logger.exception(
|
||||
"order_processing_unexpected_error",
|
||||
order_id=order_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
#### TypeScript — error logging with breadcrumbs
|
||||
|
||||
```typescript
|
||||
import { getContextLogger } from "./logger";
|
||||
|
||||
const log = getContextLogger();
|
||||
|
||||
export async function processOrder(orderId: string): Promise<Order> {
|
||||
log.info({ orderId }, "processing_order");
|
||||
|
||||
try {
|
||||
const order = await orderRepo.findById(orderId);
|
||||
if (!order) {
|
||||
log.error({ orderId }, "order_not_found");
|
||||
throw new OrderNotFoundError(orderId);
|
||||
}
|
||||
|
||||
const payment = await paymentService.charge({
|
||||
amount: order.total,
|
||||
currency: order.currency,
|
||||
customerId: order.customerId,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ orderId, paymentId: payment.id, amount: order.total },
|
||||
"payment_processed"
|
||||
);
|
||||
return order;
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentError) {
|
||||
log.error(
|
||||
{
|
||||
orderId,
|
||||
errorCode: err.code,
|
||||
errorMessage: err.message,
|
||||
err, // pino serializes Error objects with stack traces
|
||||
},
|
||||
"payment_failed"
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
{
|
||||
orderId,
|
||||
err,
|
||||
errorType: (err as Error).constructor.name,
|
||||
},
|
||||
"order_processing_unexpected_error"
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principle:** Log at the point where you have the most context, not at every layer. A single error log with full context is more useful than five partial logs scattered across the call stack.
|
||||
|
||||
### 7. Performance Logging
|
||||
|
||||
Track operation durations to identify slow endpoints, queries, and external calls.
|
||||
|
||||
#### Python — timing decorator
|
||||
|
||||
```python
|
||||
import functools
|
||||
import time
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("performance")
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
|
||||
def log_duration(operation: str, slow_threshold_ms: float = 1000.0):
|
||||
"""Decorator that logs the duration of a function call.
|
||||
|
||||
Args:
|
||||
operation: A descriptive name for the operation.
|
||||
slow_threshold_ms: Threshold in milliseconds above which
|
||||
the log level escalates to WARNING.
|
||||
"""
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
log_fn = (
|
||||
logger.warning
|
||||
if duration_ms > slow_threshold_ms
|
||||
else logger.debug
|
||||
)
|
||||
log_fn(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
slow=duration_ms > slow_threshold_ms,
|
||||
)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
log_fn = (
|
||||
logger.warning
|
||||
if duration_ms > slow_threshold_ms
|
||||
else logger.debug
|
||||
)
|
||||
log_fn(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
slow=duration_ms > slow_threshold_ms,
|
||||
)
|
||||
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper # type: ignore
|
||||
return sync_wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Usage
|
||||
@log_duration("fetch_user_profile", slow_threshold_ms=200)
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await user_repo.get_with_preferences(user_id)
|
||||
```
|
||||
|
||||
#### Python — context manager for ad-hoc timing
|
||||
|
||||
```python
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("performance")
|
||||
|
||||
@contextmanager
|
||||
def log_timing(operation: str, **extra_fields):
|
||||
"""Context manager for timing arbitrary code blocks."""
|
||||
start = time.perf_counter()
|
||||
yield
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
logger.info(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
**extra_fields,
|
||||
)
|
||||
|
||||
# Usage
|
||||
async def rebuild_search_index():
|
||||
with log_timing("rebuild_search_index", index="products"):
|
||||
products = await product_repo.get_all()
|
||||
await search_service.reindex(products)
|
||||
```
|
||||
|
||||
#### TypeScript — timing wrapper
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const perfLog = createLogger("performance");
|
||||
|
||||
export async function withTiming<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
slowThresholdMs = 1000
|
||||
): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
return result;
|
||||
} finally {
|
||||
const durationMs = performance.now() - start;
|
||||
const logFn = durationMs > slowThresholdMs ? perfLog.warn : perfLog.debug;
|
||||
logFn(
|
||||
{
|
||||
operation,
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
slow: durationMs > slowThresholdMs,
|
||||
},
|
||||
"operation_duration"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const profile = await withTiming("fetch_user_profile", () =>
|
||||
userRepo.getWithPreferences(userId)
|
||||
);
|
||||
```
|
||||
|
||||
#### Slow query logging
|
||||
|
||||
```python
|
||||
# Python — SQLAlchemy event listener for slow queries
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("database")
|
||||
|
||||
SLOW_QUERY_THRESHOLD_MS = 500
|
||||
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.perf_counter())
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total_ms = (time.perf_counter() - conn.info["query_start_time"].pop()) * 1000
|
||||
if total_ms > SLOW_QUERY_THRESHOLD_MS:
|
||||
logger.warning(
|
||||
"slow_query",
|
||||
duration_ms=round(total_ms, 2),
|
||||
statement=statement[:500], # Truncate long queries
|
||||
parameters=str(parameters)[:200],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use structured logging from day one** — start with JSON output and key-value pairs instead of formatted strings. Switching from `f"User {user_id} created"` to `logger.info("user_created", user_id=user_id)` costs nothing upfront but saves hours when debugging in production.
|
||||
|
||||
2. **Log events, not sentences** — use snake_case event names (`order_placed`, `payment_failed`) rather than prose messages (`"An order was placed by the user"`). Event names are searchable, filterable, and easy to aggregate.
|
||||
|
||||
3. **Include the right context at the right level** — every log line should include enough context to be useful in isolation: relevant IDs (user, order, request), operation name, and outcome. Avoid logging the same error at every layer of the call stack.
|
||||
|
||||
4. **Set log levels per environment** — use DEBUG in development, INFO in staging, and INFO or WARNING in production. Never leave DEBUG enabled in production — it generates excessive volume and may expose sensitive internals.
|
||||
|
||||
5. **Centralize logging configuration** — configure loggers once at application startup, not in individual modules. Every module should call `get_logger(__name__)` and inherit the shared configuration.
|
||||
|
||||
6. **Always redact sensitive data** — build redaction into the logging pipeline as a processor or serializer. Do not rely on developers remembering to exclude passwords or tokens from log calls.
|
||||
|
||||
7. **Use correlation IDs for every request** — generate a unique ID at the entry point and propagate it through all downstream calls. This is the single most important pattern for debugging distributed systems.
|
||||
|
||||
8. **Set up log rotation and retention policies** — configure maximum file sizes, rotation intervals, and retention periods. Production logs without rotation will fill disks. Use log aggregation services (ELK, Datadog, CloudWatch) rather than relying on local files.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Logging sensitive data** — passwords, API keys, JWTs, credit card numbers, and PII end up in logs more often than expected. Once written, they persist in log storage and backups. Build redaction into the pipeline rather than relying on code review to catch every instance.
|
||||
|
||||
2. **Using print() or console.log in production** — `print()` in Python and `console.log` in Node.js write to stdout without timestamps, levels, or structure. They cannot be filtered, aggregated, or searched. Replace them with a proper logger before deploying.
|
||||
|
||||
3. **Logging too much at high levels** — setting every log call to INFO or ERROR creates alert fatigue and obscures real problems. Use DEBUG for diagnostic details and reserve ERROR for situations that require action.
|
||||
|
||||
4. **Missing stack traces on errors** — logging `str(exception)` loses the stack trace. In Python, use `exc_info=True` or `logger.exception()`. In pino, pass the error as `{ err }` to get the full stack serialized.
|
||||
|
||||
5. **Not testing log output** — logging code is code. If your redaction processor has a bug, secrets leak. Write unit tests that capture log output and assert on structure, redacted fields, and expected context.
|
||||
|
||||
6. **Synchronous logging in async applications** — writing to files or network sinks synchronously from an async event loop blocks request processing. Use async-compatible transports (pino's worker thread, structlog with stdlib async handlers) or write to stdout and let the infrastructure handle routing.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/error-handling` — Exception handling patterns that complement error logging
|
||||
- `patterns/api-client` — HTTP client patterns including logging outbound requests
|
||||
- `frameworks/fastapi` — FastAPI middleware setup for request logging and correlation IDs
|
||||
- `devops/docker` — Container logging drivers and log aggregation in Docker environments
|
||||
- `databases/postgresql` — Logging database queries and slow query detection
|
||||
- `databases/mongodb` — Logging database operations and aggregation pipelines
|
||||
@@ -0,0 +1,157 @@
|
||||
# Log Levels Quick Reference
|
||||
|
||||
## Level Summary
|
||||
|
||||
| Level | When to Use | Audience | Production Default |
|
||||
|-------|------------|----------|-------------------|
|
||||
| **DEBUG** | Detailed diagnostic info | Developers debugging | Off |
|
||||
| **INFO** | Routine operational events | Ops team monitoring | On |
|
||||
| **WARNING** | Something unexpected but handled | Ops + Devs | On |
|
||||
| **ERROR** | Operation failed, needs attention | On-call engineers | On |
|
||||
| **CRITICAL** | System is unusable or data at risk | On-call + management | On + alert |
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
**Purpose**: Fine-grained information useful only when diagnosing problems.
|
||||
|
||||
**Turn on**: During local development or when investigating a specific issue.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Parsing config file: /etc/app/config.yaml` | `Entering function parse_config` |
|
||||
| `Cache miss for key user:123, fetching from DB` | `x = 5` |
|
||||
| `SQL: SELECT * FROM users WHERE id=$1 [params: 123]` | `Here we go!` |
|
||||
| `Retry attempt 2/3 for payment gateway` | `Debug debug debug` |
|
||||
| `JWT token expires at 2025-01-29T10:00:00Z` | `token = eyJhbG...` (secret!) |
|
||||
|
||||
**Rule**: Never log secrets, tokens, passwords, or PII at any level.
|
||||
|
||||
---
|
||||
|
||||
## INFO
|
||||
|
||||
**Purpose**: Confirm the system is working as expected. Key business events.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Server started on port 8080` | `Server is running` (which port? which version?) |
|
||||
| `User user:456 created account via OAuth (Google)` | `New user` |
|
||||
| `Order ord:789 placed, total=$45.00, items=3` | `Order created` |
|
||||
| `Migration v42 applied successfully (12 tables)` | `Migration done` |
|
||||
| `Scheduled job "daily-report" completed in 4.2s` | `Job finished` |
|
||||
| `Payment processed: txn:abc, amount=$99, method=card` | `Payment OK` |
|
||||
|
||||
**Rule**: Include enough context to answer "what happened, to what, and relevant numbers."
|
||||
|
||||
---
|
||||
|
||||
## WARNING
|
||||
|
||||
**Purpose**: Something unexpected happened, but the system handled it. May indicate a future problem.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Connection pool at 85% capacity (17/20)` | `Pool getting full` |
|
||||
| `Deprecated API v1 called by client app:legacy (use v2)` | `Old API used` |
|
||||
| `Disk space below 10% on /data (2.1 GB remaining)` | `Low disk` |
|
||||
| `Request took 4.8s (threshold: 5s) for GET /api/search` | `Slow request` |
|
||||
| `Config REDIS_URL missing, falling back to in-memory cache` | `No Redis` |
|
||||
| `Rate limit approaching for IP 10.0.0.5: 90/100 requests` | `Almost rate limited` |
|
||||
|
||||
**Rule**: Warnings should be actionable. If nobody would investigate, it's DEBUG or INFO.
|
||||
|
||||
---
|
||||
|
||||
## ERROR
|
||||
|
||||
**Purpose**: An operation failed. The system can continue, but something broke.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Failed to send email to user:123: SMTP timeout after 30s` | `Email error` |
|
||||
| `Payment declined for order:789: card_expired (Stripe)` | `Payment failed` |
|
||||
| `Database query timeout after 10s: SELECT FROM orders WHERE...` | `DB error` |
|
||||
| `File upload failed: S3 returned 503, bucket=media, key=img/456.jpg` | `Upload error` |
|
||||
| `Unhandled exception in POST /api/orders: ValueError("...")` | (stack trace only, no context) |
|
||||
|
||||
**Rule**: Include the operation, the target/ID, the error detail, and what was attempted.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
**Purpose**: System is unusable or data integrity is at risk. Requires immediate human intervention.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Database connection lost, all pools exhausted, 0/20 available` | `DB down` |
|
||||
| `Disk full on /data, writes failing, data loss possible` | `No disk space` |
|
||||
| `Security: 500 failed login attempts from IP 10.0.0.5 in 60s` | `Too many logins` |
|
||||
| `Data corruption detected: order:789 total=-$50.00` | `Bad data` |
|
||||
| `TLS certificate expires in 24h, auto-renewal failed` | `Cert expiring` |
|
||||
|
||||
**Rule**: Every CRITICAL log should trigger an alert (PagerDuty, Slack, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging Format
|
||||
|
||||
### Python (structlog)
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
log.info("order.placed", order_id="ord:789", total=45.00, items=3)
|
||||
log.error("email.send_failed", user_id="user:123", error="SMTP timeout", retry=2)
|
||||
```
|
||||
|
||||
### TypeScript (pino)
|
||||
|
||||
```typescript
|
||||
import pino from "pino";
|
||||
|
||||
const log = pino({ level: "info" });
|
||||
|
||||
log.info({ orderId: "ord:789", total: 45.0, items: 3 }, "order.placed");
|
||||
log.error({ userId: "user:123", err, retry: 2 }, "email.send_failed");
|
||||
```
|
||||
|
||||
### Key-Value Best Practices
|
||||
|
||||
| Field | Purpose | Example |
|
||||
|-------|---------|---------|
|
||||
| `event` / message | What happened | `"order.placed"` |
|
||||
| `request_id` | Trace across services | `"req_abc123"` |
|
||||
| `user_id` | Who triggered it | `"user:456"` |
|
||||
| `duration_ms` | How long it took | `142` |
|
||||
| `error` | Error message (not stack in prod) | `"connection refused"` |
|
||||
| `component` | Which module/service | `"payment-gateway"` |
|
||||
|
||||
---
|
||||
|
||||
## Configuration by Environment
|
||||
|
||||
| Environment | Minimum Level | Structured? | Destination |
|
||||
|-------------|--------------|-------------|-------------|
|
||||
| Local dev | DEBUG | No (human-readable) | stdout |
|
||||
| CI/Test | WARNING | No | stdout |
|
||||
| Staging | DEBUG | Yes (JSON) | Log aggregator |
|
||||
| Production | INFO | Yes (JSON) | Log aggregator |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|-------------|---------|-----|
|
||||
| Logging PII/secrets | Security/compliance violation | Redact or mask sensitive fields |
|
||||
| `log.error()` in a loop | Log flooding, storage cost | Log once with count |
|
||||
| `log.error("Error: " + err)` | Missing context, hard to search | Use structured fields |
|
||||
| Logging at wrong level | Alert fatigue or missed issues | Follow the guide above |
|
||||
| Catch-log-rethrow | Duplicate log entries | Log at the handling site only |
|
||||
| No request_id | Cannot correlate logs | Add correlation ID middleware |
|
||||
| Logging full request bodies | Performance, storage, PII risk | Log summary fields only |
|
||||
@@ -0,0 +1,782 @@
|
||||
---
|
||||
name: state-management
|
||||
description: >
|
||||
State management patterns for React and Python applications. Use this skill when choosing between useState, useReducer, context, Zustand, Jotai, or TanStack Query. Also applies to server state, form state, URL state, and Python application state with dataclasses and Pydantic. Trigger whenever someone asks about state architecture, global state, caching API responses, or managing complex form state.
|
||||
---
|
||||
|
||||
# State Management Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Choosing between local, shared, or global state in a React application
|
||||
- Setting up server state caching with TanStack Query or SWR
|
||||
- Building forms with validation, arrays, and nested fields
|
||||
- Syncing application state with URL search parameters
|
||||
- Designing Python domain models with dataclasses or Pydantic
|
||||
- Refactoring prop-drilling into a shared store
|
||||
- Deciding whether to add a state management library or keep things simple
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Static sites with no interactive state (pure content pages, docs)
|
||||
- Server-only rendering with no client-side interactivity
|
||||
- Simple CRUD backends where database is the only source of truth and there is no in-process state to manage
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Local vs Global State Decision Tree
|
||||
|
||||
Before reaching for a library, walk through this decision tree.
|
||||
|
||||
```
|
||||
Is the state used by a single component?
|
||||
├── YES --> useState or useReducer
|
||||
└── NO
|
||||
Is it shared by a parent and 1-2 direct children?
|
||||
├── YES --> Lift state up to the common parent, pass via props
|
||||
└── NO
|
||||
Is it server data (fetched from an API)?
|
||||
├── YES --> TanStack Query (useQuery / useMutation)
|
||||
└── NO
|
||||
Is it URL-representable (filters, pagination, tabs)?
|
||||
├── YES --> URL state (useSearchParams / nuqs)
|
||||
└── NO
|
||||
Is it form data with validation?
|
||||
├── YES --> react-hook-form + zod
|
||||
└── NO
|
||||
Zustand store (or Jotai for atomic state)
|
||||
```
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Start with the simplest option. Only add a library when props become painful.
|
||||
- Server state and client state are different concerns. Never put fetched API data in Zustand; use TanStack Query instead.
|
||||
- URL state is free persistence. If the user should be able to bookmark or share the current view, put it in the URL.
|
||||
- Form state belongs to the form library. Do not mirror react-hook-form values in a Zustand store.
|
||||
|
||||
---
|
||||
|
||||
### 2. React State Patterns
|
||||
|
||||
**useState for simple values**
|
||||
|
||||
```typescript
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount((prev) => prev + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useReducer for complex state with multiple transitions**
|
||||
|
||||
```typescript
|
||||
interface TimerState {
|
||||
status: "idle" | "running" | "paused";
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
type TimerAction =
|
||||
| { type: "start" }
|
||||
| { type: "pause" }
|
||||
| { type: "reset" }
|
||||
| { type: "tick" };
|
||||
|
||||
function timerReducer(state: TimerState, action: TimerAction): TimerState {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { ...state, status: "running" };
|
||||
case "pause":
|
||||
return { ...state, status: "paused" };
|
||||
case "reset":
|
||||
return { status: "idle", elapsed: 0 };
|
||||
case "tick":
|
||||
return state.status === "running"
|
||||
? { ...state, elapsed: state.elapsed + 1 }
|
||||
: state;
|
||||
}
|
||||
}
|
||||
|
||||
function Timer() {
|
||||
const [state, dispatch] = useReducer(timerReducer, {
|
||||
status: "idle",
|
||||
elapsed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "running") return;
|
||||
const id = setInterval(() => dispatch({ type: "tick" }), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.status]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{state.elapsed}s</p>
|
||||
{state.status !== "running" && (
|
||||
<button onClick={() => dispatch({ type: "start" })}>Start</button>
|
||||
)}
|
||||
{state.status === "running" && (
|
||||
<button onClick={() => dispatch({ type: "pause" })}>Pause</button>
|
||||
)}
|
||||
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When to pick which:**
|
||||
|
||||
| Criteria | useState | useReducer |
|
||||
|----------|----------|------------|
|
||||
| Single primitive value | Yes | Overkill |
|
||||
| Multiple related fields | Possible | Preferred |
|
||||
| Complex transitions | Messy | Clean |
|
||||
| Needs testing in isolation | Hard | Easy (test the reducer) |
|
||||
|
||||
---
|
||||
|
||||
### 3. Global State (Zustand)
|
||||
|
||||
Zustand is lightweight, TypeScript-friendly, and avoids the boilerplate of Redux.
|
||||
|
||||
**Basic store**
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
login: (user, token) => set({ user, token }),
|
||||
logout: () => set({ user: null, token: null }),
|
||||
}));
|
||||
|
||||
// In components - use selectors to avoid unnecessary re-renders
|
||||
function UserMenu() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
if (!user) return <LoginButton />;
|
||||
return (
|
||||
<div>
|
||||
<span>{user.name}</span>
|
||||
<button onClick={logout}>Log out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Slices pattern for large stores**
|
||||
|
||||
```typescript
|
||||
import { create, type StateCreator } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
|
||||
// Each slice is its own interface + creator
|
||||
interface CartSlice {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
interface UISlice {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const createCartSlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
CartSlice
|
||||
> = (set) => ({
|
||||
items: [],
|
||||
addItem: (item) =>
|
||||
set((state) => ({ items: [...state.items, item] })),
|
||||
removeItem: (id) =>
|
||||
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
|
||||
clearCart: () => set({ items: [] }),
|
||||
});
|
||||
|
||||
const createUISlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
UISlice
|
||||
> = (set) => ({
|
||||
sidebarOpen: false,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
});
|
||||
|
||||
// Combine slices with middleware
|
||||
const useAppStore = create<CartSlice & UISlice>()(
|
||||
devtools(
|
||||
persist(
|
||||
(...args) => ({
|
||||
...createCartSlice(...args),
|
||||
...createUISlice(...args),
|
||||
}),
|
||||
{
|
||||
name: "app-store",
|
||||
partialize: (state) => ({ items: state.items }), // only persist cart
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Zustand best practices:**
|
||||
|
||||
- Always use selectors (`useStore((s) => s.field)`) instead of the whole store.
|
||||
- Keep stores small and focused. One store per domain, not one mega-store.
|
||||
- Use `persist` middleware for state that should survive page reloads.
|
||||
- Use `devtools` middleware in development for Redux DevTools integration.
|
||||
|
||||
---
|
||||
|
||||
### 4. Server State (TanStack Query)
|
||||
|
||||
Server state (data from APIs) has different needs than client state: caching, background refetching, deduplication, pagination.
|
||||
|
||||
**Basic query**
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Query key conventions: [entity, ...params]
|
||||
const userKeys = {
|
||||
all: ["users"] as const,
|
||||
list: (filters: UserFilters) => ["users", "list", filters] as const,
|
||||
detail: (id: string) => ["users", "detail", id] as const,
|
||||
};
|
||||
|
||||
function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.detail(id),
|
||||
queryFn: () => api.users.get(id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
function UserProfile({ id }: { id: string }) {
|
||||
const { data: user, isLoading, error } = useUser(id);
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutations with optimistic updates**
|
||||
|
||||
```typescript
|
||||
function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateUserInput) => api.users.update(data.id, data),
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: userKeys.detail(newData.id),
|
||||
});
|
||||
|
||||
// Snapshot current value for rollback
|
||||
const previous = queryClient.getQueryData(userKeys.detail(newData.id));
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({
|
||||
...old,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, newData, context) => {
|
||||
// Rollback on failure
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
userKeys.detail(newData.id),
|
||||
context.previous
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, variables) => {
|
||||
// Always refetch after mutation to ensure consistency
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userKeys.detail(variables.id),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prefetching for instant navigation**
|
||||
|
||||
```typescript
|
||||
function UserListItem({ user }: { user: UserSummary }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const prefetch = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: userKeys.detail(user.id),
|
||||
queryFn: () => api.users.get(user.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
onMouseEnter={prefetch}
|
||||
onFocus={prefetch}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Form State
|
||||
|
||||
Use react-hook-form for performance (uncontrolled inputs) and zod for schema validation.
|
||||
|
||||
**Basic form with validation**
|
||||
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: z.enum(["admin", "editor", "viewer"]),
|
||||
notifications: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type CreateUserForm = z.infer<typeof createUserSchema>;
|
||||
|
||||
function CreateUserForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateUserForm>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
role: "viewer",
|
||||
notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateUserForm) => {
|
||||
await api.users.create(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input id="name" {...register("name")} />
|
||||
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="email" {...register("email")} />
|
||||
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role">Role</label>
|
||||
<select id="role" {...register("role")}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic field arrays**
|
||||
|
||||
```typescript
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
|
||||
const orderSchema = z.object({
|
||||
customer: z.string().min(1),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(999),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
type OrderForm = z.infer<typeof orderSchema>;
|
||||
|
||||
function OrderForm() {
|
||||
const { register, control, handleSubmit } = useForm<OrderForm>({
|
||||
resolver: zodResolver(orderSchema),
|
||||
defaultValues: { items: [{ productId: "", quantity: 1 }] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "items",
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<input
|
||||
{...register(`items.${index}.productId`)}
|
||||
placeholder="Product ID"
|
||||
/>
|
||||
<input
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
/>
|
||||
<button type="button" onClick={() => remove(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append({ productId: "", quantity: 1 })}
|
||||
>
|
||||
Add item
|
||||
</button>
|
||||
<button type="submit">Place order</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. URL State
|
||||
|
||||
Encode filters, pagination, and view settings in the URL so users can bookmark and share.
|
||||
|
||||
**Using nuqs (type-safe URL search params)**
|
||||
|
||||
```typescript
|
||||
import { useQueryState, parseAsInteger, parseAsStringEnum } from "nuqs";
|
||||
|
||||
const sortOptions = ["name", "date", "price"] as const;
|
||||
|
||||
function ProductList() {
|
||||
const [search, setSearch] = useQueryState("q", { defaultValue: "" });
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
|
||||
const [sort, setSort] = useQueryState(
|
||||
"sort",
|
||||
parseAsStringEnum(sortOptions).withDefault("name")
|
||||
);
|
||||
|
||||
// URL looks like: /products?q=shoes&page=2&sort=price
|
||||
const { data } = useQuery({
|
||||
queryKey: ["products", { search, page, sort }],
|
||||
queryFn: () => api.products.list({ search, page, sort }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ProductGrid products={data?.items ?? []} />
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
total={data?.totalPages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using React Router useSearchParams**
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
function FilteredList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "all";
|
||||
const page = Number(searchParams.get("page") ?? "1");
|
||||
|
||||
const updateFilter = (key: string, value: string | null) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
// Reset page when filter changes
|
||||
if (key !== "page") next.set("page", "1");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => updateFilter("status", e.target.value)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Python State
|
||||
|
||||
Use dataclasses for lightweight domain objects and Pydantic for validated external data. Combine with the repository pattern for persistence.
|
||||
|
||||
**Dataclasses for domain objects**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
CONFIRMED = "confirmed"
|
||||
SHIPPED = "shipped"
|
||||
DELIVERED = "delivered"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderItem:
|
||||
product_id: str
|
||||
quantity: int
|
||||
unit_price: float
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.quantity * self.unit_price
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
customer_id: str
|
||||
items: list[OrderItem] = field(default_factory=list)
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
status: OrderStatus = OrderStatus.DRAFT
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return sum(item.total for item in self.items)
|
||||
|
||||
def confirm(self) -> None:
|
||||
if self.status != OrderStatus.DRAFT:
|
||||
raise ValueError(f"Cannot confirm order in '{self.status}' state")
|
||||
if not self.items:
|
||||
raise ValueError("Cannot confirm an empty order")
|
||||
self.status = OrderStatus.CONFIRMED
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self.status in (OrderStatus.DELIVERED, OrderStatus.CANCELLED):
|
||||
raise ValueError(f"Cannot cancel order in '{self.status}' state")
|
||||
self.status = OrderStatus.CANCELLED
|
||||
```
|
||||
|
||||
**Pydantic for validated external input**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
customer_id: str = Field(min_length=1, max_length=50)
|
||||
items: list["OrderItemInput"] = Field(min_length=1)
|
||||
|
||||
@field_validator("items")
|
||||
@classmethod
|
||||
def no_duplicate_products(cls, items: list["OrderItemInput"]) -> list["OrderItemInput"]:
|
||||
product_ids = [item.product_id for item in items]
|
||||
if len(product_ids) != len(set(product_ids)):
|
||||
raise ValueError("Duplicate product IDs are not allowed")
|
||||
return items
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: str = Field(min_length=1)
|
||||
quantity: int = Field(ge=1, le=999)
|
||||
```
|
||||
|
||||
**Repository pattern for persistence**
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class OrderRepository(ABC):
|
||||
@abstractmethod
|
||||
async def save(self, order: Order) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, order_id: UUID) -> Order | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]: ...
|
||||
|
||||
|
||||
class PostgresOrderRepository(OrderRepository):
|
||||
def __init__(self, pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
async def save(self, order: Order) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, customer_id, status, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET status = $3
|
||||
""",
|
||||
order.id,
|
||||
order.customer_id,
|
||||
order.status.value,
|
||||
order.created_at,
|
||||
)
|
||||
# Upsert items...
|
||||
|
||||
async def get(self, order_id: UUID) -> Order | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM orders WHERE id = $1", order_id
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
items = await conn.fetch(
|
||||
"SELECT * FROM order_items WHERE order_id = $1", order_id
|
||||
)
|
||||
return self._row_to_order(row, items)
|
||||
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC",
|
||||
customer_id,
|
||||
)
|
||||
# Fetch items for each order...
|
||||
return [self._row_to_order(row, items) for row, items in results]
|
||||
|
||||
def _row_to_order(self, row, item_rows) -> Order:
|
||||
return Order(
|
||||
id=row["id"],
|
||||
customer_id=row["customer_id"],
|
||||
status=OrderStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
items=[
|
||||
OrderItem(
|
||||
product_id=r["product_id"],
|
||||
quantity=r["quantity"],
|
||||
unit_price=float(r["unit_price"]),
|
||||
)
|
||||
for r in item_rows
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start local, promote when needed.** Begin with `useState`. Only move state up or into a store when two or more unrelated components need the same data. Premature globalization makes refactoring painful.
|
||||
|
||||
2. **Separate server state from client state.** Use TanStack Query (or SWR) for anything fetched from an API. These libraries handle caching, deduplication, background refetching, and stale-while-revalidate. Do not duplicate fetched data in Zustand.
|
||||
|
||||
3. **Use selectors to prevent re-renders.** In Zustand, always select the specific field you need: `useStore((s) => s.count)`, not `useStore()`. The latter re-renders on every store change.
|
||||
|
||||
4. **Co-locate state with the component that owns it.** If only `<Sidebar>` uses `isOpen`, keep that state inside `<Sidebar>`. Moving it to a global store just because "it might be needed later" creates unnecessary coupling.
|
||||
|
||||
5. **Derive, do not duplicate.** If `fullName` can be computed from `firstName` and `lastName`, compute it on the fly or with `useMemo`. Storing derived values introduces synchronization bugs.
|
||||
|
||||
6. **Validate at the boundary, trust internally.** Use Pydantic or Zod to validate data when it enters the system (API requests, form submissions, external events). Once validated, pass typed objects without re-checking.
|
||||
|
||||
7. **Keep URL state minimal.** Only encode values the user would want to bookmark or share: active tab, search query, page number, sort column. Do not put ephemeral UI state (hover, open dropdown) in the URL.
|
||||
|
||||
8. **Treat form state as its own domain.** Let react-hook-form manage form values, dirty tracking, and validation. Submit the validated result to your mutation or API call. Do not synchronize form fields with external stores.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Putting everything in global state.** Not all state needs to be global. A modal's open/closed state, an input's current text, or a component's loading spinner should stay local. Global stores should hold state that genuinely needs to be shared across distant parts of the tree.
|
||||
|
||||
2. **Storing server data in Zustand.** Zustand has no built-in cache invalidation, stale detection, or background refetch. Using it for API data means you are rebuilding TanStack Query poorly. Use the right tool for the job.
|
||||
|
||||
3. **Forgetting to invalidate queries after mutations.** After a `useMutation` succeeds, call `queryClient.invalidateQueries` with the affected keys. Without this, the UI shows stale data until the next refetch interval.
|
||||
|
||||
4. **Over-using React Context for frequently changing state.** Every Context value change re-renders every consumer. Context is good for low-frequency values (theme, locale, auth). For high-frequency updates (cursor position, scroll offset), use Zustand or a ref.
|
||||
|
||||
5. **Duplicating form state.** Calling `useForm()` and then also storing the same values in `useState` or Zustand means two sources of truth that can drift apart. Let the form library be the single owner.
|
||||
|
||||
6. **Ignoring URL state for filterable lists.** If a user applies filters and then hits the back button or refreshes, losing the filters is a bad experience. Encode filters in the URL so they survive navigation.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frameworks/react` - React component patterns and hooks
|
||||
- `frameworks/nextjs` - Next.js server components and data fetching
|
||||
- `languages/typescript` - TypeScript types and generics
|
||||
- `patterns/caching` - Cache strategies and invalidation patterns
|
||||
@@ -0,0 +1,143 @@
|
||||
# State Management Decision Tree
|
||||
|
||||
## Primary Decision Tree
|
||||
|
||||
```
|
||||
What kind of state is it?
|
||||
│
|
||||
├─ SERVER DATA (fetched from API/DB)
|
||||
│ │
|
||||
│ ├─ React/Next.js project?
|
||||
│ │ ├─ YES ──> TanStack Query (React Query)
|
||||
│ │ │ - Auto caching, dedup, background refresh
|
||||
│ │ │ - Stale-while-revalidate out of the box
|
||||
│ │ │ - DevTools for debugging
|
||||
│ │ │
|
||||
│ │ └─ Next.js App Router with Server Components?
|
||||
│ │ └─ Consider: fetch() in Server Components + revalidation
|
||||
│ │ - No client-side state library needed
|
||||
│ │ - Use TanStack Query only for client-interactive data
|
||||
│ │
|
||||
│ └─ Need real-time sync?
|
||||
│ ├─ WebSocket data ──> TanStack Query + custom subscription
|
||||
│ └─ Collaborative ──> Liveblocks, Yjs, or Partykit
|
||||
│
|
||||
├─ URL STATE (filters, pagination, search, tabs)
|
||||
│ │
|
||||
│ ├─ Next.js App Router ──> useSearchParams() + useRouter()
|
||||
│ ├─ React Router ──> useSearchParams()
|
||||
│ └─ Plain React ──> nuqs or custom URL sync hook
|
||||
│
|
||||
│ Why URL state? Shareable links, back/forward navigation,
|
||||
│ bookmarkable, SSR-friendly.
|
||||
│
|
||||
├─ FORM STATE (input values, validation, dirty/touched)
|
||||
│ │
|
||||
│ ├─ Complex forms (multi-step, dynamic fields, arrays)
|
||||
│ │ └─ react-hook-form + zod
|
||||
│ │ - Uncontrolled by default (performant)
|
||||
│ │ - Schema validation with zod resolver
|
||||
│ │
|
||||
│ ├─ Simple forms (login, search, contact)
|
||||
│ │ └─ Server Actions (Next.js) or native form + useState
|
||||
│ │
|
||||
│ └─ Form + server state (edit existing record)
|
||||
│ └─ TanStack Query (fetch) + react-hook-form (edit)
|
||||
│ - Populate form with query data
|
||||
│ - Submit with mutation
|
||||
│
|
||||
├─ GLOBAL CLIENT STATE (shared across many components)
|
||||
│ │
|
||||
│ ├─ Simple (theme, sidebar open, user preferences)
|
||||
│ │ │
|
||||
│ │ ├─ Changes rarely ──> React Context
|
||||
│ │ │ (Wrap app, useContext to consume)
|
||||
│ │ │
|
||||
│ │ └─ Changes often or many consumers ──> Zustand
|
||||
│ │ - Avoids Context re-render problem
|
||||
│ │ - Selector-based subscriptions
|
||||
│ │ - Tiny bundle, minimal boilerplate
|
||||
│ │
|
||||
│ ├─ Complex (shopping cart, multi-step wizard, editor)
|
||||
│ │ └─ Zustand (or Jotai for atomic state)
|
||||
│ │
|
||||
│ └─ Need devtools and time-travel debugging?
|
||||
│ └─ Zustand with devtools middleware
|
||||
│
|
||||
├─ LOCAL COMPONENT STATE (only used in one component)
|
||||
│ │
|
||||
│ ├─ Single value ──> useState
|
||||
│ │ const [count, setCount] = useState(0);
|
||||
│ │
|
||||
│ ├─ Related values or complex transitions ──> useReducer
|
||||
│ │ const [state, dispatch] = useReducer(reducer, initial);
|
||||
│ │
|
||||
│ └─ Derived value (computed from other state) ──> useMemo
|
||||
│ const total = useMemo(() => items.reduce(...), [items]);
|
||||
│
|
||||
└─ TRANSIENT UI STATE (animations, hover, drag position)
|
||||
│
|
||||
├─ CSS can handle it? ──> Use CSS (transitions, :hover)
|
||||
├─ Ref-based (no re-render needed) ──> useRef
|
||||
└─ Needs re-render ──> useState (local)
|
||||
```
|
||||
|
||||
## Quick Lookup Table
|
||||
|
||||
| State Type | Recommended Tool | When NOT to Use |
|
||||
|-----------|-----------------|-----------------|
|
||||
| Server data | TanStack Query | Data never changes, or SSR-only |
|
||||
| URL params | useSearchParams | Ephemeral UI state (hover, etc.) |
|
||||
| Form inputs | react-hook-form | Single `<input>` |
|
||||
| Global UI | Zustand | Only 1-2 consumers (use Context) |
|
||||
| Global UI (simple) | React Context | Frequent updates with many consumers |
|
||||
| Local state | useState | Complex state transitions |
|
||||
| Complex local | useReducer | Single boolean toggle |
|
||||
| Derived data | useMemo | Cheap computations |
|
||||
| No re-render needed | useRef | Value that should trigger re-render |
|
||||
|
||||
## Library Comparison
|
||||
|
||||
| Library | Bundle Size | Boilerplate | Learning Curve | Best For |
|
||||
|---------|------------|-------------|----------------|----------|
|
||||
| useState/useReducer | 0 KB | Minimal | Low | Local state |
|
||||
| React Context | 0 KB | Low | Low | Rarely-changing global state |
|
||||
| Zustand | ~1 KB | Minimal | Low | Global client state |
|
||||
| Jotai | ~3 KB | Minimal | Medium | Atomic/derived state |
|
||||
| TanStack Query | ~12 KB | Medium | Medium | Server state |
|
||||
| Redux Toolkit | ~30 KB | High | High | Large teams needing strict patterns |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| Storing server data in Zustand/Redux | Manual cache invalidation, stale data | Use TanStack Query |
|
||||
| Storing URL state in useState | Not shareable, lost on refresh | Use URL search params |
|
||||
| Putting everything in global state | Unnecessary re-renders, complexity | Colocate state where used |
|
||||
| Context for frequently changing data | Re-renders all consumers | Use Zustand with selectors |
|
||||
| Duplicating derived state | Out-of-sync bugs | Compute with useMemo |
|
||||
| useState for complex transitions | Inconsistent intermediate states | Use useReducer |
|
||||
|
||||
## Zustand Quick Setup
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface CartStore {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
total: () => number;
|
||||
}
|
||||
|
||||
const useCartStore = create<CartStore>((set, get) => ({
|
||||
items: [],
|
||||
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
|
||||
removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
|
||||
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
|
||||
}));
|
||||
|
||||
// Usage with selector (only re-renders when items change)
|
||||
const items = useCartStore((s) => s.items);
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
```
|
||||
Reference in New Issue
Block a user