Every gRPC call ends with a status code carried in trailers — not the HTTP :status. A small, fixed set of codes plus optional rich details.
200 — even when the call fails. The real outcome is in the grpc-status trailer (a number) and grpc-message (text). grpc-status: 0 means OK.
Because the status rides in trailers, a server can start streaming a response and then fail partway — the trailer at the end tells the client whether the stream completed cleanly.
The status space is fixed and small. Learn the handful you'll actually set:
| # | Code | Meaning | Retry? |
|---|---|---|---|
| 0 | OK | Success | — |
| 1 | CANCELLED | Caller cancelled | No |
| 3 | INVALID_ARGUMENT | Client sent bad input (independent of state) | No |
| 4 | DEADLINE_EXCEEDED | Ran out of time budget | Maybe |
| 5 | NOT_FOUND | Entity doesn't exist | No |
| 6 | ALREADY_EXISTS | Create conflict | No |
| 7 | PERMISSION_DENIED | Authenticated but not allowed | No |
| 8 | RESOURCE_EXHAUSTED | Quota / rate limit hit | Backoff |
| 9 | FAILED_PRECONDITION | State is wrong for the op | No |
| 10 | ABORTED | Concurrency conflict (e.g. txn) | Yes |
| 13 | INTERNAL | Server bug / invariant broken | No |
| 14 | UNAVAILABLE | Transient — down, restarting, no conn | Yes |
| 16 | UNAUTHENTICATED | Missing / invalid credentials | No |
FAILED_PRECONDITION vs ABORTED vs UNAVAILABLE. Precondition = don't retry until the client fixes state. Aborted = retry the whole transaction. Unavailable = retry the same call with backoff.
import grpc
def GetUser(self, request, context):
if not request.user_id:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "user_id is required")
user = db.load(request.user_id)
if user is None:
context.abort(grpc.StatusCode.NOT_FOUND, f"no user {request.user_id}")
return to_proto(user)
context.abort() raises, ends the RPC immediately, and sets the trailer. Prefer it over returning a sentinel message.
import grpc
try:
resp = stub.GetUser(req, timeout=1.0)
except grpc.RpcError as e:
code = e.code() # grpc.StatusCode
if code == grpc.StatusCode.NOT_FOUND:
return None
if code == grpc.StatusCode.UNAVAILABLE:
return retry_with_backoff() # transient
raise # everything else bubbles
A code + string is often not enough. gRPC supports rich status: a google.rpc.Status whose details field carries Any-packed Protobuf messages — machine-readable structure alongside the human message.
BadRequest — per-field validation violations.QuotaFailure — which quota, current usage.RetryInfo — a server-suggested retry_delay the client should honor.ErrorInfo — a stable reason string + domain for programmatic handling.RetryInfo tells the client exactly how long to wait; a BadRequest maps straight onto form fields. This is the same well-typed philosophy as the Protobuf payload itself.
At the edge — a gateway, gRPC-Web, or a REST transcoder (see gRPC vs REST) — gRPC codes get translated to HTTP. The canonical mapping:
| gRPC status | HTTP status |
|---|---|
OK | 200 |
INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE | 400 |
UNAUTHENTICATED | 401 |
PERMISSION_DENIED | 403 |
NOT_FOUND | 404 |
ALREADY_EXISTS, ABORTED | 409 |
RESOURCE_EXHAUSTED | 429 |
CANCELLED | 499 (client closed) |
INTERNAL, DATA_LOSS, UNKNOWN | 500 |
UNIMPLEMENTED | 501 |
UNAVAILABLE | 503 |
DEADLINE_EXCEEDED | 504 |
400 and 500. That's exactly why gRPC has its own richer code set — HTTP status alone loses the distinction between "your input was bad," "the state was wrong," and "you're out of range." Compare the HTTP side in the HTTP guides.