Skip to content

Errors

2 min read · main part only; folded sections are optional

After this you can answer: How does a failure deep in the API become a message the user can understand?

flowchart LR
S["service throws<br/>ConflictError"] --> M["errorMapper<br/>kind → tRPC code"]
M --> F["errorFormatter<br/>clean message, no stack"]
F --> B["browser: 409<br/>'Not enough seats left'"]
X["unexpected crash"] --> F2["500: 'Something went wrong'<br/>details only in the log"]
  1. Throw a kind, not a status. errors.ts defines AppError with a kind: NOT_FOUND, CONFLICT, FORBIDDEN, BAD_REQUEST, UNAUTHORIZED, TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE.
  2. Map it once. errorMapper wraps every procedure. If the error is an AppError, it throws the matching tRPC error, which becomes the HTTP status.
  3. Clean it. errorFormatter replaces the message of any 500 with “Something went wrong”, turns Zod failures into “Invalid input” plus per-field messages, and always drops the stack.
  4. Show it. The booking form shows the API’s message only for 400, 404 and 409, which are meant for people. Anything else shows a general message.
KindHTTPExample message
NOT_FOUND404“Event not found”
BAD_REQUEST400“This event has already started”
CONFLICT409“Not enough seats left”
UNAUTHORIZED / FORBIDDEN401 / 403“Only admins can do this”
TOO_MANY_REQUESTS429with a Retry-After header
SERVICE_UNAVAILABLE503the limiter cannot reach Valkey

The error classes are in apps/api/src/errors.ts → AppError, with no tRPC or Fastify imports. The mapper and the formatter are in apps/api/src/trpc/init.ts. A rate limit error also carries retryAfterSeconds, so the page can say how long to wait.

See the API app on the map →

CloudWatch → /minitickets/dev/api: a 500 has its full error and stack in the log line, with the request’s correlationId. The browser only got “Something went wrong”.

SymptomCauseWhere to look
Users see “Something went wrong” for a sold-out eventA service threw a plain Error, so it became a 500Throw ConflictError
A stack trace reaches the browserThe formatter was bypassederrorFormatter
The page retries a 409 foreverRetrying a 4xxRetry only network errors and 5xx
Why not throw TRPCError straight from the service?

Then services depend on tRPC and HTTP. Swapping the transport, or calling a service from a script or a worker, would drag HTTP along. One mapper keeps the translation in one place.

Which errors should the browser retry?

Network errors and 5xx, maybe. Never 4xx: the same request gets the same answer. Only bookings retry automatically, and only because each carries an idempotency key.

How does a user report an error you can find?

Every response has an x-correlation-id header, and every log line of that request carries the same id. One search in the logs finds the whole story.

Advanced: Plain Fastify routes and batches

Routes outside tRPC, like /health, have their own Fastify error handler in apps/api/src/app.ts. tRPC can batch several calls in one HTTP request; when more than one is rate-limited, the mapper keeps the longest Retry-After.

Answer these without scrolling up. If one feels hard, reread that section.

  1. Why do services throw a kind instead of an HTTP status?
  2. Where is the one place kinds become status codes?
  3. What does the formatter do to a 500?
  4. Which messages does the booking form show as they are?
  5. How do you find the full error behind “Something went wrong”?

Redraw it from memory: service throws → errorMapper → errorFormatter → browser, and the crash path to 500

Try this: on the local stack, book more seats than are left and read the status code and message in the Network tab

Read this book online: https://book.theversehub.in/