Lesson 8 · Errors, deadlines & metadata
Status codes & the error model
Every RPC ends with exactly one status — the protocol's own error model.
Your win: explain how gRPC reports errors, map a domain error to a
codes.* status at the right layer, and know the "rich error model" exists.
(You met status codes in Go
course Lesson 12 — here's the gRPC-native framing.)
Not an exception — a status
Every gRPC call ends with exactly one status: OK, or one
of ~17 well-defined error codes, plus an optional message. This is the protocol's
error model — language-independent, understood by every gRPC client in every
language.1 In Go you build one with
status.Error(codes.X, msg).
| Code | Meaning | Our use |
|---|---|---|
OK | success | — |
InvalidArgument | bad input | validation failures |
NotFound / AlreadyExists | missing / duplicate | duplicates (via errorx elsewhere) |
PermissionDenied | authn/authz failure | all auth failures |
ResourceExhausted | limit exceeded | size limits |
DeadlineExceeded / Canceled | timed out / cancelled | Lesson 9 |
Unavailable | transient; retry | triggers client retry |
Internal / Unknown | infra / panic | wrapped errors; recovery |
Map at the boundary — same rule as Go L12
internal/spike/.../grpc/email_modifier_send_email.go:20if errors.Is(err, ErrMediasSizeExceed) { return nil, status.Error(codes.ResourceExhausted, err.Error()) }
if errors.Is(err, ErrInternal) { return nil, status.Error(codes.Internal, err.Error()) }
return nil, status.Error(codes.InvalidArgument, err.Error())
Domain/usecase code returns plain wrapped errors; the controller translates to a status. Keeps business logic transport-agnostic (reusable from a job or another transport).
The rich error model
Beyond code + message, gRPC lets you attach structured details — extra protobuf messages describing the failure. Our shared PG→status mapper does this:
internal/golibs/errorx/errorx.go:56stt := status.New(codes.AlreadyExists, e.Message)
stt, _ = stt.WithDetails(&errdetails.PreconditionFailure{...}) // structured detail
return stt.Err()
(⚠️ Repo note: your three services call status.Error directly;
errorx.ToStatusError is used by other services. Same model, though.)
UnaryAccessControlErrorHandlingInterceptor turns a Postgres row-level-
security violation buried in a codes.Internal into a clean
codes.InvalidArgument; a framework interceptor remaps "context canceled"
→ codes.Canceled. Cross-cutting error shaping lives in interceptors
(Part 4), not every handler.
gRPC — Error handling + Status codes guides
The official error model (code + message + optional rich details) and the canonical list of codes with when-to-use guidance.
→ grpc.io/docs/guides/error
→ grpc.io/docs/guides/status-codes
Check yourself (from memory)
Q1. Every gRPC response carries…
Q2. The rich error model adds…
status.WithDetails
(e.g. PreconditionFailure) — our errorx uses it.
Q3. Errors become codes.* statuses in our services…
InvalidArgument/NotFound/PermissionDenied/
Internal) + an optional message — the protocol's own, language-independent
model. Optionally attach structured details with status.WithDetails (the
"rich error model"; our errorx.ToStatusError does this for PG errors). Our
services translate domain errors → status.Error(codes.X) ONLY at the
controller boundary, keeping usecase/repo layers transport-agnostic (same rule as Go L12).status.FromError(err),
branch on st.Code()) and its details? Ask me.