用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/CleanExpo/Unite-Group --skill api-client命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
基于 SOC 职业分类
| name | api-client |
| type | skill |
| version | 1.0.0 |
| priority | 2 |
| domain | backend |
| description | Type-safe fetch wrapper with interceptors, retry, and error normalisation for Next.js API clients |
Type-safe fetch wrapper with interceptors, request/response transforms, and automatic retry for NodeJS-Starter-V1.
| Field | Value |
|---|---|
| Skill ID | api-client |
| Category | API & Integration |
| Complexity | Medium |
| Complements | retry-strategy, error-taxonomy, |
rate-limiter| Version | 1.0.0 |
| Locale | en-AU |
Codifies type-safe API client patterns for NodeJS-Starter-V1: typed fetch wrappers for browser and server components, request/response interceptors, automatic 429 retry with Retry-After, error normalisation with ApiClientError, httpx async client patterns for Python, and upgrade paths for the existing apiClient and serverApiClient.
webhook-handler skill)rate-limiter skill)graphql-patterns skill)any, no untyped dict.ApiClientError with status code, error code, and human-readable message. Never throw raw Error.import type { ApiError } from "./types";
export class ApiClientError extends Error {
constructor(
message: string,
public status: number,
public errorCode?: string,
public retryAfter?: number,
) {
super(message);
this.name = "ApiClientError";
}
get isRetryable(): boolean {
return this.status === 429 || this.status >= 500;
}
get isAuthError(): boolean {
return this.status === 401 || this.status === 403;
}
}
type Interceptor = (config: RequestInit & { url: string }) => RequestInit & { url: string };
type ResponseHandler = (response: Response) => Promise<Response>;
class TypedApiClient {
private interceptors: Interceptor[] = [];
private responseHandlers: ResponseHandler[] = [];
constructor(private baseUrl: string) {}
use(interceptor: Interceptor): this {
this.interceptors.push(interceptor);
return this;
}
useResponse(handler: ResponseHandler): this {
this.responseHandlers.push(handler);
return this;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
let config = { ...options, url: `${this.baseUrl}${endpoint}` };
for (const interceptor of this.interceptors) {
config = interceptor(config);
}
const { url, ...fetchOptions } = config;
let response = await fetch(url, fetchOptions);
for (const handler of this.responseHandlers) {
response = await handler(response);
}
if (!response.ok) {
const body: ApiError = await response.json().catch(() => ({
detail: `HTTP ${response.status}`,
}));
throw new ApiClientError(
body.detail,
response.status,
body.error_code,
parseRetryAfter(response),
);
}
if (response.status === 204) return {} as T;
return response.json();
}
get = <T>(endpoint: string) => this.request<T>(endpoint, { method: "GET" });
post = <T>(endpoint: string, data?: unknown) =>
this.request<T>(endpoint, {
method: "POST",
body: data ? JSON.stringify(data) : undefined,
headers: { "Content-Type": "application/json" },
});
put = <T>(endpoint: string, data?: unknown) =>
this.request<T>(endpoint, {
method: "PUT",
body: data ? JSON.stringify(data) : undefined,
headers: { "Content-Type": "application/json" },
});
delete = <T>(endpoint: string) => this.request<T>(endpoint, { method: "DELETE" });
}
function parseRetryAfter(response: Response): number | undefined {
const header = response.headers.get("retry-after");
return header ? parseFloat(header) : undefined;
}
Project Reference: apps/web/lib/api/client.ts:1-132 — the existing apiClient is a plain object with get/post/put/patch/delete methods but no interceptors, no retry, and no typed error with retryAfter. Replace the inner fetchApi function with TypedApiClient.request().
function authInterceptor(config: RequestInit & { url: string }) {
const token = getAuthToken(); // from existing client.ts
if (token) {
const headers = new Headers(config.headers);
headers.set("Authorization", `Bearer ${token}`);
config.headers = Object.fromEntries(headers.entries());
}
return config;
}
function loggingInterceptor(config: RequestInit & { url: string }) {
const start = performance.now();
const originalUrl = config.url;
console.debug(`[API] ${config.method ?? "GET"} ${originalUrl}`);
return config;
}
function snakeCaseInterceptor(config: RequestInit & { url: string }) {
if (config.body && typeof config.body === "string") {
const parsed = JSON.parse(config.body);
config.body = JSON.stringify(toSnakeCase(parsed));
}
return config;
}
Composition: client.use(authInterceptor).use(loggingInterceptor).use(snakeCaseInterceptor)
function retryHandler(maxRetries = 3): ResponseHandler {
let attempt = 0;
return async (response: Response): Promise<Response> => {
if (response.status !== 429 || attempt >= maxRetries) return response;
attempt++;
const retryAfter = response.headers.get("retry-after");
const waitMs = retryAfter
? parseFloat(retryAfter) * 1000
: Math.min(1000 * 2 ** attempt, 30_000);
await new Promise((r) => setTimeout(r, waitMs));
return fetch(response.url, { method: response.type });
};
}
Complements: rate-limiter skill — the server returns Retry-After headers; this handler respects them. retry-strategy skill — for non-429 transient errors, use the full exponential backoff from that skill.
import { cookies } from "next/headers";
function createServerClient(options?: { revalidate?: number }) {
const client = new TypedApiClient(
process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8000",
);
client.use((config) => {
const cookieStore = cookies();
const token = cookieStore.get("auth_token")?.value;
if (token) {
const headers = new Headers(config.headers);
headers.set("Authorization", `Bearer ${token}`);
config.headers = Object.fromEntries(headers.entries());
}
// Default to no-store unless revalidation specified
if (options?.revalidate !== undefined) {
(config as any).next = { revalidate: options.revalidate };
}
return config;
});
return client;
}
Project Reference: apps/web/lib/api/server.ts:1-129 — the existing serverApiClient duplicates the entire browser client with cookies() import. Replace with createServerClient() that reuses TypedApiClient.
from typing import Any, TypeVar
from pydantic import BaseModel
import httpx
from src.config import get_settings
from src.utils import get_logger
T = TypeVar("T", bound=BaseModel)
logger = get_logger(__name__)
class BackendClient:
"""Typed httpx client for internal service calls."""
def __init__(self, base_url: str | None = None, timeout: float = 30.0) -> None:
settings = get_settings()
self.base_url = base_url or settings.backend_url
self.timeout = timeout
async def request(
self, method: str, path: str, response_model: type[T] | None = None, **kwargs: Any
) -> T | dict:
async with httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout
) as client:
response = await client.request(method, path, **kwargs)
response.raise_for_status()
data = response.json()
if response_model:
return response_model(**data)
return data
async def get(self, path: str, response_model: type[T] | None = None) -> T | dict:
return await self.request("GET", path, response_model)
async def post(self, path: str, data: BaseModel | dict | None = None, response_model: type[T] | None = None) -> T | dict:
json_data = data.model_dump() if isinstance(data, BaseModel) else data
return await self.request("POST", path, response_model, json=json_data)
Project Reference: apps/backend/src/models/ollama_provider.py:1-50 — uses raw httpx.AsyncClient inline. The BackendClient pattern provides a reusable wrapper with Pydantic model deserialisation.
function toSnakeCase(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const snakeKey = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
result[snakeKey] = value && typeof value === "object" && !Array.isArray(value)
? toSnakeCase(value as Record<string, unknown>)
: value;
}
return result;
}
function toCamelCase(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
result[camelKey] = value && typeof value === "object" && !Array.isArray(value)
? toCamelCase(value as Record<string, unknown>)
: value;
}
return result;
}
The FastAPI backend returns snake_case; the Next.js frontend uses camelCase. Apply toCamelCase as a response handler and toSnakeCase as a request interceptor to bridge the gap automatically.
| Pattern | Problem | Correct Approach |
|---|---|---|
| Duplicating auth header in every call | Drift, inconsistency | Auth interceptor applied once |
Raw fetch without error normalisation | Inconsistent error shapes | ApiClientError with status + code |
Ignoring Retry-After on 429 | Client retries immediately, worsens load | Parse header, wait, then retry |
| Separate browser and server client codebases | Double maintenance | Shared TypedApiClient with env-specific interceptors |
any return types on API calls | No type safety at call sites | Generic request<T> with type parameter |
Inline httpx.AsyncClient everywhere | Connection pool churn | Shared BackendClient instance |
Before merging api-client changes:
TypedApiClient class with interceptor chain replaces raw fetchApiApiClientError includes status, errorCode, retryAfter, isRetryablecookies() (server)Retry-After header on 429 responsesTypedApiClient with cache controlBackendClient uses Pydantic model deserialisationfetchApi implementations across client/serverWhen applying this skill, structure implementation as:
### API Client Implementation
**Environment**: [browser / server / Python / all]
**Interceptors**: [auth, logging, snake-case, retry]
**Error Class**: ApiClientError with [status, errorCode, retryAfter]
**Retry**: [429 with Retry-After / transient / disabled]
**Case Conversion**: [snake ↔ camel / disabled]
**Migration**: [upgrade existing apiClient / new client]