How the gRPC status model works
Every gRPC call ends with a status, whether it succeeded or not. The status has three parts: a code from a fixed set of canonical values (OK is the only success), a human-readable message, and optional binary details. On the wire, the server sends these in HTTP/2 trailers after any response messages. Because the status arrives in trailers, a streaming call can deliver several messages and still end in failure.
The code and details are the machine-readable contract. The message is not. Messages get reworded, localized, or wrapped by middleware. A client that matches on "user not found" will break the first time someone fixes a typo. Branch on the code, and when you need more precision, branch on typed details such as a field violation or a retry delay. Section 3 covers those details.
Not every status comes from your handler. The gRPC library and the transport produce some codes on their own:
UNAVAILABLEwhen a connection can't be established or drops.DEADLINE_EXCEEDEDwhen the client's deadline expires, sometimes before the request reaches the server.CANCELLEDwhen either side abandons the call.UNKNOWNwhen a handler throws an exception the framework can't map to a code.
So a client can't assume a given code reflects a deliberate decision by service code. That ambiguity is why errors belong in the API contract, and it affects two groups:
- Backend engineers who own services decide which codes and details their handlers emit.
- Platform engineers who own shared client configuration decide which codes trigger retries, backoff, or failover across every caller.
+----------------+ +---------------------+ +----------------+
| Client code | call | gRPC library and | call | Service handler|
| |------->| HTTP/2 transport |------->| |
| |<-------| |<-------| |
+----------------+ status +---------------------+ status +----------------+
^ Can produce on its own: Emits chosen codes
| UNAVAILABLE and details
| DEADLINE_EXCEEDED (owned by backend
| CANCELLED engineers)
| UNKNOWN (unmapped exception)
|
+-------+------------------------------------------------------------+
| Status in HTTP/2 trailers (after any response messages) |
| code : OK, NOT_FOUND, UNAVAILABLE, ... -> branch on this |
| message : human-readable, can change -> never parse it |
| details : typed binary, e.g. field -> branch on this |
| violation, retry delay |
+-------+------------------------------------------------------------+
|
v
+--------------------------------------------------------------------+
| Shared client config (owned by platform engineers): |
| which codes trigger retries, backoff or failover for every caller |
+--------------------------------------------------------------------+
A gRPC status (code, message, optional details) reaches the client in HTTP/2 trailers and may come from the service handler or from the library and transport, so clients and shared retry config should act on the code and typed details and never on the message text.
Choosing grpc status codes by what the caller should do next
Start from the caller's next move, not from what went wrong inside the server. If two failures need the same reaction from the caller, they can share a code. If the caller must react differently, they need different codes.
| Code | What happened | Caller's next action |
|---|---|---|
INVALID_ARGUMENT |
Request is malformed, whatever the system state | Fix the request; never retry unchanged |
FAILED_PRECONDITION |
Request is valid, but the system isn't ready (e.g. deleting a non-empty folder) | Fix the system state first, then retry |
OUT_OF_RANGE |
Valid type, but past a boundary such as the end of a list | Stop iterating or request a different page |
ABORTED |
Concurrency conflict, such as a stale version on write | Re-read and redo the whole read-modify-write |
UNAVAILABLE |
Transient failure (connection reset, pod draining) | Retry with backoff |
INTERNAL |
Server invariant broken; a bug | Don't retry blindly; alert the service owner |
RESOURCE_EXHAUSTED |
Quota or rate limit hit | Back off or ask for a higher quota |
UNAUTHENTICATED |
Credentials missing or expired | Refresh the token and retry once |
PERMISSION_DENIED |
Identity is known but not allowed | Request access; retrying won't help |
NOT_FOUND |
Resource absent, or deliberately hidden | Check the identifier |
Return NOT_FOUND instead of PERMISSION_DENIED when confirming that a resource exists would leak information, such as another tenant's IDs. The cost is that legitimate callers and on-call engineers can't tell a typo from a missing grant. Record the real reason in server logs.
Fine-grained codes help clients branch, but every code you return becomes part of the contract. Once a client checks for FAILED_PRECONDITION, switching that path to ABORTED breaks the client without warning. For that reason, a small set of codes chosen on purpose is usually safer than full precision.
Rich error details with google.rpc.Status
A status code tells the caller what kind of failure happened. google.rpc.Status carries the specifics. It has three fields: an integer code, a developer-facing message, and details, a repeated google.protobuf.Any. Because each detail is packed as Any, a client can unpack only the types it recognizes and safely ignore the rest.
The standard detail types live in google/rpc/error_details.proto:
| Type | Carries | Typical use |
|---|---|---|
ErrorInfo |
reason, domain, metadata map |
Stable machine key for client branching |
BadRequest |
Field violations (field path, description) | Form or payload validation |
RetryInfo |
retry_delay |
Server-advised backoff |
QuotaFailure |
Quota violations by subject | Rate or quota exhaustion |
PreconditionFailure |
Violations with type and subject | Unmet state requirements |
ResourceInfo |
Resource type, name, owner | Identifying what is missing or locked |
DebugInfo |
Stack entries, detail | Internal debugging only |
LocalizedMessage |
Locale and text | Messages safe to show end users |
Clients should branch on ErrorInfo.reason plus domain, not on message. Message text changes. A reason such as INSUFFICIENT_CREDITS is a contract.
On the wire, the server serializes the full Status into the binary grpc-status-details-bin trailer. Each language exposes it through its own helper. Go uses the status package with WithDetails, Java uses StatusProto, and Python uses grpcio-status. The language-agnostic pattern works like this:
- Server: build a
StatuswithINVALID_ARGUMENT, then attach anErrorInfoand aBadRequest. - Client: convert the error to a
Status, iterate overdetails, and type-switch on each one.
Rich details come with costs:
- Client dependencies: clients need the proto definitions and the matching helper library.
- Size limits: payloads count against header and trailer size limits, which proxies may enforce.
- Intermediaries: some gateways strip or rewrite trailers.
- Exposure: never send
DebugInfoacross a trust boundary.
For simple internal services, a code-only contract with a small set of stable messages is often enough.
import grpc
from grpc_status import rpc_status
from google.protobuf import any_pb2
from google.rpc import code_pb2, error_details_pb2, status_pb2
# Server side: inside a servicer method, attach ErrorInfo and BadRequest.
def reject_order(context):
info = error_details_pb2.ErrorInfo(
reason="INSUFFICIENT_CREDITS", # stable machine key, part of the contract
domain="billing.example.com",
metadata={"account_id": "acct-1234"},
)
bad = error_details_pb2.BadRequest(field_violations=[
error_details_pb2.BadRequest.FieldViolation(
field="order.quantity", description="must be greater than 0"),
])
detail_info, detail_bad = any_pb2.Any(), any_pb2.Any()
detail_info.Pack(info)
detail_bad.Pack(bad)
status = status_pb2.Status(
code=code_pb2.INVALID_ARGUMENT,
message="order rejected", # for developers, not for branching
details=[detail_info, detail_bad],
)
# Serialized into the grpc-status-details-bin trailer.
context.abort_with_status(rpc_status.to_status(status))
# Client side: recover the Status and unpack only the types we recognize.
def handle(err: grpc.RpcError):
status = rpc_status.from_call(err)
if status is None: # no rich details: fall back to the code
return err.code()
for detail in status.details:
if detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR):
info = error_details_pb2.ErrorInfo()
detail.Unpack(info)
if (info.domain, info.reason) == ("billing.example.com", "INSUFFICIENT_CREDITS"):
print("prompt user to top up", info.metadata.get("account_id"))
elif detail.Is(error_details_pb2.BadRequest.DESCRIPTOR):
bad = error_details_pb2.BadRequest()
detail.Unpack(bad)
for v in bad.field_violations:
print(f"{v.field}: {v.description}")
# Unknown detail types are ignored.
A Python sketch using grpcio-status in which a server attaches ErrorInfo and BadRequest to an INVALID_ARGUMENT status, and the client unpacks those details and branches on domain plus reason rather than on the message text.
Implementing server-side error mapping and deadlines
Handlers should not choose status codes ad hoc. If each one decides independently, NotFound from one service and FailedPrecondition from another will end up meaning the same thing. Instead, handlers return domain errors, and a single server interceptor translates them. The example below maps known error types to codes and sends everything else to a catch-all.
Rules for the mapping layer
- Never put raw exception text or stack traces in the status message. They leak table names, file paths and library versions. They also tempt clients to parse prose.
- Keep
DebugInfointernal. Attach it only when the caller sits inside your trust boundary, and strip it at the edge. - Log the full error with a correlation ID and return that ID to the client, either in the message or in a
RequestInfodetail. Support can then find the log line without the client ever seeing internals.
The catch-all that turns unknown errors into INTERNAL keeps clients safe, but it also hides mistakes. A validation error you forgot to map will show up as a server fault, and nobody will notice. To catch this, emit a metric for every catch-all hit, labeled by the original error type, and alert when it grows.
Deadlines are error handling
- Every client call sets a deadline. Without one, a stuck backend holds connections and threads indefinitely.
- Propagate the remaining deadline downstream. Do not start a fresh timeout for each hop.
- Preserve
DEADLINE_EXCEEDEDfrom dependencies. Rewrapping it asINTERNALorUNAVAILABLEmisleads both retry logic and dashboards. - Expect deadline expiry after the work is done. The server may have committed the write just before the client gave up. The client sees a failure for an operation that succeeded, which is why idempotency matters before any retry.
import logging, uuid
import grpc
log = logging.getLogger("rpc")
class NotFoundError(Exception): pass
class ValidationError(Exception): pass
# The only place that decides what a domain error means on the wire.
ERROR_CODES = {NotFoundError: grpc.StatusCode.NOT_FOUND,
ValidationError: grpc.StatusCode.INVALID_ARGUMENT}
def record_catchall(method, error_type):
pass # increment a counter labeled by method and error_type; alert on growth
class ErrorMappingInterceptor(grpc.ServerInterceptor):
def intercept_service(self, continuation, handler_call_details):
handler = continuation(handler_call_details)
if handler is None or handler.unary_unary is None:
return handler # sketch covers unary-unary only
method = handler_call_details.method
def wrapped(request, context):
try: # handlers raise domain errors; they do not call context.abort
return handler.unary_unary(request, context)
except Exception as err:
cid = str(uuid.uuid4())
# Full error and stack trace stay in server logs, keyed by cid.
log.exception("rpc failed method=%s cid=%s", method, cid)
code = ERROR_CODES.get(type(err))
if isinstance(err, grpc.RpcError) and err.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
code = grpc.StatusCode.DEADLINE_EXCEEDED # keep downstream deadline expiry honest
if code is None: # catch-all: safe for clients, so make it visible to operators
record_catchall(method, type(err).__name__)
code = grpc.StatusCode.INTERNAL
# Fixed message plus correlation ID; never str(err) or a traceback.
context.abort(code, f"request failed (correlation_id={cid})")
return grpc.unary_unary_rpc_method_handler(
wrapped,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer)
A Python gRPC server interceptor that maps domain errors to status codes from one table, keeps downstream DEADLINE_EXCEEDED unchanged, logs the full error under a correlation ID, and counts every catch-all INTERNAL so misclassified errors show up in metrics.
Configuring a grpc retry policy, throttling and hedging
Client retries in gRPC are declared in the service config, not in application code. Each methodConfig entry is scoped by name: a service alone applies to every method, and a service plus method targets one RPC. Its retryPolicy sets maxAttempts, which includes the original call, and the backoff fields initialBackoff, maxBackoff and backoffMultiplier. It also sets retryableStatusCodes. For an idempotent read, UNAVAILABLE is the usual candidate. Every retry still has to fit inside the caller's deadline.
The top-level retryThrottling block is a client-side brake. It is a token bucket defined by maxTokens and tokenRatio. Failures drain the bucket and successes refill it. When the level drops low enough, the client stops retrying, so a struggling backend does not get hit with even more traffic.
Transparent retries are separate from this. The library may resend a request that never reached the server application, such as a stream the server refused. It can do this because no handler ran.
hedgingPolicy is an alternative to retryPolicy for latency-sensitive idempotent reads. You configure one or the other on a method. Hedging sends extra copies after hedgingDelay and keeps the first response. This cuts tail latency, but every hedge is real server work, so treat it as a capacity decision.
Support differs across language implementations. A config that one client honors may be silently ignored by another. Check your language's documentation and test retry behavior in each language you ship.
| Approach | Strength | Risk |
|---|---|---|
| Library retries | Status-aware, throttled, deadline-bound | Uneven support across languages |
| Application loop | Full control, visible in code | Easy to retry non-idempotent calls |
| Mesh or sidecar | Uniform across languages | Stacks with client retries and multiplies load |
{
"methodConfig": [
{
"name": [
{ "service": "catalog.v1.CatalogService", "method": "GetItem" }
],
"timeout": "2s",
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
},
{
"name": [
{ "service": "catalog.v1.CatalogService" }
],
"timeout": "5s"
}
],
"retryThrottling": {
"maxTokens": 10,
"tokenRatio": 0.1
}
}
A service config that retries only the idempotent GetItem read on UNAVAILABLE (up to 4 attempts including the original) within a 2s timeout. Other methods on the service get a timeout and no retries, and retryThrottling caps retries client-wide.
When retries are unsafe: idempotency, overload and streaming
Idempotency is the gate for every retry decision. Retrying UNAVAILABLE broadly is safe for reads and naturally idempotent writes such as "set X to 5", because the request either never reached a handler or repeating it changes nothing. Mutations like "charge card" or "append event" need an idempotency key or request ID that the server records and checks before acting.
DEADLINE_EXCEEDED and INTERNAL are different from UNAVAILABLE. They tell you nothing about whether the server committed the work. A timeout can fire after the database write succeeded, so a retry duplicates the side effect. For non-idempotent methods, keep both out of retryableStatusCodes. Idempotency keys fix the duplicate, but they create their own design work: where keys are stored, how long they live, and what a replay returns when the original request is still in flight.
Retries also multiply across layers. If the client, the sidecar or mesh, and the load balancer each make three attempts, one failing call can become 27 requests at the backend. That is an illustrative estimate, not a measurement. Pick one layer to own the retry policy and enable throttling there. Throttling limits retries when the failure rate rises, so it matters more than tuning backoff values. Retrying RESOURCE_EXHAUSTED without honoring the server's RetryInfo delay sends more load to a server that has already said it is overloaded.
Streaming adds another constraint. The status arrives in trailers, often after the client has already received and processed some messages. Built-in retries stop once response headers or messages have been received. To resume a stream after a failure, the application needs its own checkpoints or resume tokens so the client can ask to continue from the last message it processed.
Impact on cost and engineering time, plus a team error convention checklist
Most of the cost comes at the start. Someone has to write the mapping table from domain errors to status codes. The team has to agree on a shared proto package for error details. Each language you ship needs a thin helper library so engineers attach and read ErrorInfo the same way everywhere. Idempotency keys need a store with a retention window at least as long as your longest retry chain, and that store has its own capacity limits and failure modes. Retry behavior also needs testing in each client language, because retry support and service-config handling vary between gRPC implementations. A fault-injection test that returns UNAVAILABLE on the first attempt and succeeds on the second catches most misconfigurations.
The payback comes from removing guesswork. Clients branch on the status code and ErrorInfo reason instead of parsing message strings, so rewording an error message no longer breaks a caller. On-call engineers can filter by status code and reason to separate a quota problem from a dependency outage without reading logs line by line. Retry behavior lives in configuration rather than in hand-written loops, so it is predictable under load. Retry storms and duplicate writes become rarer.
Team error convention checklist
- A code-mapping table from each domain error to a status code, reviewed whenever the API changes.
- Required
ErrorInforeasons for each domain, with a stable domain string. - A list of which methods are idempotent and which require an idempotency key.
- Retry config stored and versioned alongside the service definition.
- Metrics broken down by method and status code, on both client and server.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to grpc error handling, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.