Calling Your API — Cheat Sheet
Full-Stack Integration · 4 topics. Download the PDF or the Instagram carousel and share it.
Cheat Sheet · AiCanCode.org
Calling Your API
Full-Stack Integration4 topicsQuick revision reference
1
Designing the Contract
Status codes, response shapes and an error format the frontend can actually branch on. Agreeing these once removes an entire category of argument.
- ✓Return honest status codes — a 200 with success:false breaks caching, monitoring and retries
- ✓401 means authenticate, 403 means not permitted; swapping them causes redirect loops
- ✓Errors need a stable machine-readable code, because messages get reworded and translated
- ✓Wrap collections in an envelope so pagination can be added later without a breaking change
- ✓Use ISO 8601 with a timezone and integer minor units for money; additive changes are safe, renames are not
The status is part of the payload
200 OK read, or an update that returns the resource
201 Created + a Location header
204 No Content a successful delete, no body
400 Bad Request malformed — unparseable JSON, wrong types
401 Unauthorized not authenticated (the name is a misnomer)
403 Forbidden authenticated, not permitted
404 Not Found missing, or hidden from this user on purpose
409 Conflict duplicate, or a concurrent edit
422 Unprocessable well-formed but fails validation
429 Too Many rate limited — include Retry-After
500 Server Error your bug
503 Unavailable dependency down; include Retry-After
// The anti-pattern, still common:
200 OK { "success": false, "error": "Not found" }
// Every caching layer, monitor and retry policy now thinks this
// succeeded, and the frontend must inspect the body to know.
// 401 vs 403 decides client behaviour: 401 -> refresh or sign in,
// 403 -> show a forbidden or upgrade screen. Getting these backwards
// causes redirect loops.2
The Client Layer
One module that owns the base URL, auth, error normalisation and types. Components should never see a fetch call or a status code.
- ✓A single wrapper owns the base URL, credentials, JSON parsing and error normalisation so components never see a status code
- ✓Normalise failures into one ApiError carrying status, a stable code, field errors and a request id
- ✓Generate types from the backend OpenAPI schema and fail CI on drift rather than hand-writing interfaces
- ✓Server-side rendering has no browser to attach cookies — forward them explicitly or the call is anonymous
- ✓Keep transport, per-endpoint functions and data hooks in separate layers, with components importing only the hooks
One place for base URL, credentials and errors
// lib/api/client.ts
const BASE = process.env.NEXT_PUBLIC_API_URL
export class ApiError extends Error {
constructor(readonly status: number, readonly code: string,
readonly fields?: Record<string, string>,
readonly requestId?: string) {
super(code)
}
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include', // cookies, every time
headers: {
'Content-Type': 'application/json',
'X-Request-Id': crypto.randomUUID(), // correlate with server logs
...init.headers,
},
})
if (res.status === 204) return undefined as T
const body = await res.json().catch(() => null)
if (!res.ok) {
const e = body?.error ?? {}
throw new ApiError(res.status, e.code ?? 'unknown', e.fields,
res.headers.get('X-Request-Id') ?? undefined)
}
return body as T
}
// Callers never see a status code:
export const listProblems = (p: ListParams) =>
api<Paged<Problem>>(`/problems?${new URLSearchParams(p)}`)3
Errors Across the Seam
A failure has to travel from a database constraint to a message a user understands, without losing the detail an engineer needs to diagnose it.
- ✓Translate expected database and domain failures into typed API errors with actionable codes
- ✓A catch-all handler logs the detail and returns only a generic message plus a request id
- ✓Map codes to UI messages in one place so every screen fails consistently
- ✓Distinguish the user's mistake, their permissions and your bug — each needs different words and affordances
- ✓Carry a request id from the client through the server and back, exposing the header so the browser can read it
Specific codes for expected failures
# Broken — the frontend cannot distinguish these
try:
await db.create_user(payload)
except Exception as e:
raise HTTPException(500, str(e)) # leaks SQL, unactionable
# Translated
try:
await db.create_user(payload)
except UniqueViolation as e:
if "users_email_key" in str(e):
raise AppError(409, "email_taken", "That email is already registered",
fields={"email": "Already registered"})
raise
except ForeignKeyViolation:
raise AppError(422, "invalid_reference", "That college no longer exists")
# And a catch-all that logs everything and reveals nothing:
@app.exception_handler(Exception)
async def unhandled(request, exc):
log.exception("unhandled", request_id=request.state.request_id,
path=request.url.path, user_id=getattr(request.state, "user_id", None))
return JSONResponse(500, {"error": {
"code": "internal_error",
"message": "Something went wrong on our side",
"request_id": request.state.request_id}})
# The user gets an id they can quote. You get the stack trace.4
Timeouts, Retries and Idempotency
A request with no timeout can hang forever, a retry without idempotency can charge a card twice, and retrying in a loop turns a slow backend into a dead one.
- ✓fetch has no default timeout — set one, and budget timeouts decreasing from browser to database
- ✓Retry network errors and 5xx, never a 4xx, and honour Retry-After on a 429
- ✓Exponential backoff needs jitter, or clients synchronise and knock over a recovering backend
- ✓A POST is only safe to retry with an idempotency key generated per operation and reused across attempts
- ✓The server must reserve the idempotency key inside the transaction, or concurrent duplicates both execute
A timeout budget, decreasing inward
// fetch has no default timeout. A hung connection waits forever.
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
// Combining a timeout with user cancellation
const ctrl = new AbortController()
const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(10_000)])
# Server side, calling anything else
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as c:
r = await c.get(url)
# And a database statement timeout, so one bad query cannot pin a
# connection for minutes
SET statement_timeout = '5s';
// Budget them downwards through the stack. If the browser waits 10s,
// the API must give up before that, and its database calls before
// that again — otherwise the user sees a timeout while work is still
// running and consuming resources.
// browser 10s > API 8s > outbound HTTP 5s > query 3s
// Pick the number from the p99, not from a round figure: a timeout
// below your real latency turns a slow endpoint into a broken one.Learn this free with Aria, your AI tutor → AiCanCode.org/learn/full-stack