Errors
After this you can answer: How does a failure deep in the API become a message the user can understand?
Picture it
Section titled “Picture it”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"]How it works
Section titled “How it works”- Throw a kind, not a status.
errors.tsdefinesAppErrorwith akind: NOT_FOUND, CONFLICT, FORBIDDEN, BAD_REQUEST, UNAUTHORIZED, TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE. - Map it once.
errorMapperwraps every procedure. If the error is anAppError, it throws the matching tRPC error, which becomes the HTTP status. - Clean it.
errorFormatterreplaces the message of any 500 with “Something went wrong”, turns Zod failures into “Invalid input” plus per-field messages, and always drops the stack. - 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.
In MiniTickets
Section titled “In MiniTickets”| Kind | HTTP | Example message |
|---|---|---|
| NOT_FOUND | 404 | “Event not found” |
| BAD_REQUEST | 400 | “This event has already started” |
| CONFLICT | 409 | “Not enough seats left” |
| UNAUTHORIZED / FORBIDDEN | 401 / 403 | “Only admins can do this” |
| TOO_MANY_REQUESTS | 429 | with a Retry-After header |
| SERVICE_UNAVAILABLE | 503 | the 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 it in AWS
Section titled “See it in AWS”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”.
aws logs tail /minitickets/dev/api --since 30m --filter-pattern error
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| Users see “Something went wrong” for a sold-out event | A service threw a plain Error, so it became a 500 | Throw ConflictError |
| A stack trace reaches the browser | The formatter was bypassed | errorFormatter |
| The page retries a 409 forever | Retrying a 4xx | Retry only network errors and 5xx |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”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.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- Why do services throw a kind instead of an HTTP status?
- Where is the one place kinds become status codes?
- What does the formatter do to a 500?
- Which messages does the booking form show as they are?
- 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/