Home/Learn/Full-Stack Integration/Errors Across the Seam

Errors Across the Seam

Advanced
Calling Your API

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.

Overview

Errors cross the boundary badly by default. A unique-constraint violation becomes a 500 and a red toast saying "Something went wrong", the user cannot act on it, and the engineer has nothing to search for. Handling it properly means three separate translations: the backend converts its internal failure into a typed API error, the client converts that into a UI state, and both sides share a request id so a support message maps to a log line. None of it is complicated; all of it is skipped under deadline and then rebuilt after the first support backlog.

Translating Server Failures

Catch the specific ones; return a code the client can act on.

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.

Turning Codes Into UI

One mapping, so every screen handles a failure the same way.

One describe() for the whole app
function describe(err: unknown): { message: string; action?: Action } {
  if (!(err instanceof ApiError)) return { message: 'Something went wrong' }

  switch (err.code) {
    case 'validation_failed':   return { message: 'Check the highlighted fields' }
    case 'email_taken':         return { message: 'That email is already registered',
                                         action: { label: 'Sign in', to: '/login' } }
    case 'insufficient_credits':return { message: 'You are out of credits',
                                         action: { label: 'Top up', to: '/billing' } }
    case 'rate_limited':        return { message: 'Too many attempts. Try again shortly.' }
    default:
      switch (err.status) {
        case 401: return { message: 'Please sign in again' }
        case 403: return { message: 'You do not have access to this' }
        case 404: return { message: 'That is no longer available' }
        default:  return { message: `Something went wrong (${err.requestId})` }
      }
  }
}

// Distinguish the three failure classes, because they need different
// words and different affordances:
//   the user's input      -> point at the field
//   their permissions     -> explain, offer an upgrade or sign-in
//   our fault             -> apologise, offer retry, show the id

// A network failure is not an API error at all — fetch rejects with
// a TypeError, and "check your connection" is the honest message.

Correlation and Visibility

Following a single request from the browser to a log line.

One id, both ends, both logs
// Generate on the client, echo through the server, return it back
X-Request-Id: 01HX3K8Q...

# FastAPI middleware
@app.middleware("http")
async def request_id(request, call_next):
    rid = request.headers.get("x-request-id") or str(uuid4())
    request.state.request_id = rid
    with structlog.contextvars.bound_contextvars(request_id=rid):
        response = await call_next(request)
    response.headers["X-Request-Id"] = rid
    return response
# Remember to add it to Access-Control-Expose-Headers, or the browser
# cannot read it back.

// Report client-side with the same id, so the two ends join up
Sentry.captureException(err, { tags: { requestId: err.requestId } })

// What to log server-side: the request id, path, user id, duration,
// status. NEVER the request body of an auth or payment endpoint.

// The end-to-end debugging path this buys you:
//   a user quotes an id -> find the server log line -> see the stack
//   trace, the user, the path and the timing, without asking them to
//   reproduce anything.

// Also alert on the shape of failures, not only the count: a jump in
// 401s means auth broke, a jump in 422s usually means the frontend
// started sending something new.

Key Points to Remember

  • 1Translate expected database and domain failures into typed API errors with actionable codes
  • 2A catch-all handler logs the detail and returns only a generic message plus a request id
  • 3Map codes to UI messages in one place so every screen fails consistently
  • 4Distinguish the user's mistake, their permissions and your bug — each needs different words and affordances
  • 5Carry a request id from the client through the server and back, exposing the header so the browser can read it

Interview Questions

Sign in to ask Aria
1

How should a unique-constraint violation surface to the user?

Medium
2

What belongs in a client-facing error and what belongs only in the logs?

Medium
3

How do you trace one user's failed request across the frontend and backend?

Hard

Ask Aria about Errors Across the Seam

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…