When a gRPC call fails, all you get at first glance is a number: grpc-status: 12 in a trailer, StatusCode.UNAVAILABLE in a stack trace, or rpc error: code = DeadlineExceeded in a log line. The gRPC status codes are a fixed set of 17 values shared by every gRPC implementation, and each one points at a different layer of the problem — the network, the server, the proto contract, or your own request. This reference covers all 17 codes, what actually triggers the ones you'll see in production, and which are safe to retry.
The full gRPC status code table
| Code | Name | Meaning in practice |
|---|---|---|
| 0 | OK | Success — never appears in errors |
| 1 | CANCELLED | The caller gave up: client disconnected, context cancelled |
| 2 | UNKNOWN | An unhandled exception escaped the server handler |
| 3 | INVALID_ARGUMENT | Request is malformed regardless of system state |
| 4 | DEADLINE_EXCEEDED | The call outlived its deadline |
| 5 | NOT_FOUND | The requested entity doesn't exist |
| 6 | ALREADY_EXISTS | Create failed because the entity exists |
| 7 | PERMISSION_DENIED | Authenticated, but not allowed |
| 8 | RESOURCE_EXHAUSTED | Rate limit, quota, or message-size limit hit |
| 9 | FAILED_PRECONDITION | System not in the right state for this call |
| 10 | ABORTED | Concurrency conflict — transaction abort, optimistic-lock failure |
| 11 | OUT_OF_RANGE | Valid argument type, out-of-range value (e.g. seek past EOF) |
| 12 | UNIMPLEMENTED | The server doesn't have this method |
| 13 | INTERNAL | Broken invariant inside the server or transport |
| 14 | UNAVAILABLE | Transient transport failure — can't reach a healthy server |
| 15 | DATA_LOSS | Unrecoverable data corruption |
| 16 | UNAUTHENTICATED | Missing or invalid credentials |
The codes you'll actually debug
UNAVAILABLE (14) is the most common production error, and it's a transport problem, not an application one: the server is down or restarting, a load balancer has no healthy backend, TLS negotiation failed, or DNS returned nothing. The classic companion message is failed to pick subchannel — the client's connection pool has no usable connection. Check server health and connectivity first; if it correlates with deploys, your server isn't draining connections gracefully on shutdown.
DEADLINE_EXCEEDED (4) means the deadline you set expired — the call may still have succeeded on the server after the client stopped waiting, which is why idempotency matters before you retry. Chronic deadline errors are a latency problem in disguise: find where the time went with distributed tracing rather than raising the deadline until the symptom disappears. The distinction is the same one behind connection timeouts vs read timeouts: failing to connect and failing to answer in time are different failures with different fixes.
UNIMPLEMENTED (12) almost always means a contract mismatch, not a missing feature: the client and server were built from different .proto versions, the method path differs (package rename!), or you're talking to the wrong service behind a shared load balancer. A plain HTTP/1.1 proxy in the middle that can't speak HTTP/2 can also surface as UNIMPLEMENTED. Diff the generated code on both sides before touching anything else.
INTERNAL (13) is reserved for broken invariants: serialization failures, HTTP/2 protocol errors (RST_STREAM frames map here), or a proxy mangling the response. If you see it paired with received RST_STREAM with error code 2, suspect the transport chain — sidecars, proxies, and HTTP/2 keepalive settings — before your handler code.
UNKNOWN (2) is what the framework sends when your handler throws something that isn't a gRPC status — a raw RuntimeException in Java, an uncaught Error in Node. Every UNKNOWN in your logs is a bug that should be caught and mapped to a meaningful code.
Read status codes as a blame pointer: UNAVAILABLE/DEADLINE_EXCEEDED → infrastructure and capacity; UNIMPLEMENTED → build/deploy mismatch; INVALID_ARGUMENT/FAILED_PRECONDITION → the caller; INTERNAL/UNKNOWN/DATA_LOSS → the server team's pager.
Which codes are safe to retry
Retrying the wrong code turns one failure into a cascade. The rule of thumb:
- Retry (with backoff + jitter):
UNAVAILABLE, andABORTEDat a higher level where you can re-run the whole transaction.RESOURCE_EXHAUSTEDonly if it's a momentary rate limit — respect anyRetryInfothe server attaches. - Retry only if the call is idempotent:
DEADLINE_EXCEEDEDandCANCELLED— the first attempt may have committed. - Never retry:
INVALID_ARGUMENT,NOT_FOUND,ALREADY_EXISTS,PERMISSION_DENIED,UNAUTHENTICATED,UNIMPLEMENTED,FAILED_PRECONDITION— the same request will fail the same way.
Blind retries against an overloaded backend are one of the standard triggers for a cascading failure; cap attempts and budget retries per client. gRPC supports declarative retry policy in the service config for exactly this:
{
"methodConfig": [{
"name": [{ "service": "checkout.CheckoutService" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}
Returning useful errors from your own services
A bare code wastes the caller's time. Attach a message and, where the client can act on it, structured details (google.rpc.Status with types like BadRequest or RetryInfo):
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.store.Find(ctx, req.Id)
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "order %q not found", req.Id)
}
if err != nil {
return nil, status.Errorf(codes.Internal, "loading order: %v", err)
}
return order, nil
}
Map your domain errors deliberately — NOT_FOUND vs FAILED_PRECONDITION vs INVALID_ARGUMENT — instead of letting everything collapse into UNKNOWN. Your future on-call self reads these codes on a dashboard.
Monitoring gRPC status codes in production
A single UNAVAILABLE is noise; a step-change in the rate of UNAVAILABLE after a deploy is an incident. That's a monitoring problem more than a debugging one: track error rates per method and per code, alert on the transient codes spiking, and treat any nonzero UNKNOWN/INTERNAL as a bug to fix. Wiring an interceptor into your error tracker takes an afternoon — the full setup, including attaching request context and trace IDs to every captured error, is covered in tracking gRPC service errors. In a service mesh, pair per-code error rates with tracing across services so an UNAVAILABLE in the checkout service points at the actual failing dependency, and put deliberate failure handling in front of dependencies you don't control.
Start tracking errors in minutes
Send your gRPC errors to LightTrace with the Sentry SDK you already use — get them grouped by method and status code, with the full trace showing which service actually failed.