🚦 Status Codes & Errors

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.

Where the Result Lives

The HTTP status is almost always 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.
sequenceDiagram participant C as Client participant S as Server C->>S: HEADERS + DATA (request) S-->>C: HEADERS :status=200 S-->>C: DATA (response, if any) S-->>C: TRAILERS grpc-status=5, grpc-message="user not found" Note over C: client raises NOT_FOUND

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 17 Codes

The status space is fixed and small. Learn the handful you'll actually set:

#CodeMeaningRetry?
0OKSuccess
1CANCELLEDCaller cancelledNo
3INVALID_ARGUMENTClient sent bad input (independent of state)No
4DEADLINE_EXCEEDEDRan out of time budgetMaybe
5NOT_FOUNDEntity doesn't existNo
6ALREADY_EXISTSCreate conflictNo
7PERMISSION_DENIEDAuthenticated but not allowedNo
8RESOURCE_EXHAUSTEDQuota / rate limit hitBackoff
9FAILED_PRECONDITIONState is wrong for the opNo
10ABORTEDConcurrency conflict (e.g. txn)Yes
13INTERNALServer bug / invariant brokenNo
14UNAVAILABLETransient — down, restarting, no connYes
16UNAUTHENTICATEDMissing / invalid credentialsNo
The classic mix-up: 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.

Returning an Error (Server)

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.

Handling an Error (Client)

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

Rich Error Details

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.

Why it matters: the client can react to structure instead of grepping an English string. A 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.

Mapping to HTTP Status Codes

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 statusHTTP status
OK200
INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE400
UNAUTHENTICATED401
PERMISSION_DENIED403
NOT_FOUND404
ALREADY_EXISTS, ABORTED409
RESOURCE_EXHAUSTED429
CANCELLED499 (client closed)
INTERNAL, DATA_LOSS, UNKNOWN500
UNIMPLEMENTED501
UNAVAILABLE503
DEADLINE_EXCEEDED504
Real-world: notice how many gRPC codes collapse onto HTTP 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.