Cheat SheetsFull-Stack IntegrationCalling Your API

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