Errors Across the Seam
AdvancedA 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.
# 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.
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.
// 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 AriaHow should a unique-constraint violation surface to the user?
What belongs in a client-facing error and what belongs only in the logs?
How do you trace one user's failed request across the frontend and backend?
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.