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).

CodeMeaningOur use
OKsuccess
InvalidArgumentbad inputvalidation failures
NotFound / AlreadyExistsmissing / duplicateduplicates (via errorx elsewhere)
PermissionDeniedauthn/authz failureall auth failures
ResourceExhaustedlimit exceededsize limits
DeadlineExceeded / Canceledtimed out / cancelledLesson 9
Unavailabletransient; retrytriggers client retry
Internal / Unknowninfra / panicwrapped errors; recovery

Map at the boundary — same rule as Go L12

internal/spike/.../grpc/email_modifier_send_email.go:20
if 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:56
stt := 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.)

Anchor — interceptors can rewrite codes notification adds interceptors that remap statuses: an 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.
Read this next

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…

One code (OK or an error) + optional message — the protocol's own, language-independent error model.

Q2. The rich error model adds…

Extra protobuf messages via status.WithDetails (e.g. PreconditionFailure) — our errorx uses it.

Q3. Errors become codes.* statuses in our services…

The controller translates; deeper layers return plain wrapped errors. Same layering rule as Go L12.
How does gRPC report an error, and where do our services decide the code?
recall, then click to reveal
Every RPC ends with exactly one status = a code (OK or one of ~17 like 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).
Want to see how a client reads a status (status.FromError(err), branch on st.Code()) and its details? Ask me.

1. gRPC — Error handling; Status codes.