Definition
- gRPC status codes – Numeric codes carried in the HTTP/2 trailer that describe the RPC outcome independently of HTTP status codes, including
OK(0),INVALID_ARGUMENT(3),NOT_FOUND(5),PERMISSION_DENIED(7),UNAVAILABLE(14), andINTERNAL(13). - HTTP mapping – Gateways translate gRPC codes to HTTP codes for REST clients:
INVALID_ARGUMENTto 400,NOT_FOUNDto 404,PERMISSION_DENIEDto 403,UNAVAILABLEto 503. - Rich error model –
google.rpc.Statusmessage carrying the code, a message, and typed detail messages likeErrorInfo,BadRequest, andRetryInfopacked into the trailer. StatusProto– Java utility class to convert betweengoogle.rpc.StatusandStatusRuntimeExceptionfor building and parsing rich errors.
When a transfer at National Bank of India fails, the teller does not just hand back a blank rejection. gRPC status codes are like a small set of official stamp categories on the rejection slip: "wrong form" (INVALID_ARGUMENT), "account not found" (NOT_FOUND), "not allowed" (PERMISSION_DENIED), "come back later, counter busy" (UNAVAILABLE). These are the bank's own stamps, separate from the building's generic "closed" signs (HTTP codes). The rich error details are the attached explanation sheet that says exactly which box on the form was wrong and what to fix.
Understanding the Concepts
What are gRPC status codes?
Every gRPC call ends with a status, which is a numeric code plus an optional message, sent in the HTTP/2 trailer. Each code has a precise meaning that drives client behavior.
| Code | Name | Meaning |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELLED | Caller cancelled the call |
| 3 | INVALID_ARGUMENT | Client sent a bad value |
| 4 | DEADLINE_EXCEEDED | Call ran past its deadline |
| 5 | NOT_FOUND | Entity does not exist |
| 6 | ALREADY_EXISTS | Create conflicts with existing entity |
| 7 | PERMISSION_DENIED | Authenticated but not authorized |
| 12 | UNIMPLEMENTED | Method not implemented |
| 13 | INTERNAL | Server bug |
| 14 | UNAVAILABLE | Transient outage, retryable |
| 16 | UNAUTHENTICATED | No valid identity |
package com.bank.grpc;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
public class AccountGrpcService
extends AccountServiceGrpc.AccountServiceImplBase {
@Override
public void getBalance(
BalanceRequest request,
StreamObserver<BalanceResponse> observer) {
String accountId = request.getAccountId();
if (accountId == null || accountId.isBlank()) {
observer.onError(
Status.INVALID_ARGUMENT
.withDescription("Account ID must not be empty")
.asRuntimeException()
);
return;
}
Account account = findAccount(accountId);
if (account == null) {
observer.onError(
Status.NOT_FOUND
.withDescription("Account " + accountId
+ " not found")
.asRuntimeException()
);
return;
}
observer.onNext(BalanceResponse.newBuilder()
.setBalance(account.getBalance().toPlainString())
.build());
observer.onCompleted();
}
}
Why does choosing the right code matter for retries?
The status code drives client retry behavior. UNAVAILABLE and sometimes DEADLINE_EXCEEDED are safe to retry because the failure is transient. INVALID_ARGUMENT and NOT_FOUND must not be retried because they will always fail the same way.
package com.bank.grpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.math.BigDecimal;
public class TransferClient {
public BigDecimal transferWithRetry(
String from, String to, String amount) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
return doTransfer(from, to, amount);
} catch (StatusRuntimeException e) {
Status.Code code = e.getStatus().getCode();
if (code == Status.Code.UNAVAILABLE) {
System.out.println("Retry " + (i + 1)
+ ": service unavailable");
continue; // Safe to retry
}
if (code == Status.Code.INVALID_ARGUMENT) {
throw e; // Do NOT retry
}
throw e; // Unknown, do not retry
}
}
throw new RuntimeException("Transfer failed after retries");
}
private BigDecimal doTransfer(
String from, String to, String amount) {
// Stub call
return new BigDecimal("50000.00");
}
}
How do gRPC codes map to HTTP codes in gateways?
When a gRPC-Web or REST gateway sits in front, it maps gRPC codes to HTTP status codes.
| gRPC Code | HTTP Status |
|---|---|
| OK (0) | 200 |
| INVALID_ARGUMENT (3) | 400 |
| UNAUTHENTICATED (16) | 401 |
| PERMISSION_DENIED (7) | 403 |
| NOT_FOUND (5) | 404 |
| ALREADY_EXISTS (6) | 409 |
| RESOURCE_EXHAUSTED (8) | 429 |
| INTERNAL (13) | 500 |
| UNIMPLEMENTED (12) | 501 |
| UNAVAILABLE (14) | 503 |
How do you return rich error details?
The richer error model packs a google.rpc.Status message into the trailer. It carries the code, a message, and a list of typed detail messages. Standard detail types include ErrorInfo, BadRequest, and RetryInfo.
package com.bank.grpc;
import com.google.protobuf.Any;
import com.google.rpc.BadRequest;
import com.google.rpc.ErrorInfo;
import io.grpc.protobuf.StatusProto;
import io.grpc.stub.StreamObserver;
import java.math.BigDecimal;
public class TransferGrpcService
extends TransferServiceGrpc.TransferServiceImplBase {
@Override
public void transfer(
TransferRequest request,
StreamObserver<TransferResponse> observer) {
BigDecimal amount = new BigDecimal(request.getAmount());
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
// Rich error with BadRequest field violation
BadRequest badRequest = BadRequest.newBuilder()
.addFieldViolations(
BadRequest.FieldViolation.newBuilder()
.setField("amount")
.setDescription(
"Amount must be positive, got: "
+ amount.toPlainString())
.build()
).build();
com.google.rpc.Status status =
com.google.rpc.Status.newBuilder()
.setCode(com.google.rpc.Code.INVALID_ARGUMENT_VALUE)
.setMessage("Invalid transfer request")
.addDetails(Any.pack(badRequest))
.build();
observer.onError(
StatusProto.toStatusRuntimeException(status));
return;
}
// Process transfer...
observer.onNext(TransferResponse.newBuilder()
.setSuccess(true).build());
observer.onCompleted();
}
}
How does the client parse rich error details?
The client extracts detail messages from the exception using StatusProto.fromThrowable().
package com.bank.client;
import com.google.rpc.BadRequest;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
public class TransferErrorHandler {
public void handleError(StatusRuntimeException e) {
com.google.rpc.Status status =
StatusProto.fromThrowable(e);
if (status != null) {
System.out.println("Code: " + status.getCode());
System.out.println("Message: " + status.getMessage());
for (com.google.protobuf.Any detail
: status.getDetailsList()) {
if (detail.is(BadRequest.class)) {
try {
BadRequest br = detail.unpack(
BadRequest.class);
for (BadRequest.FieldViolation fv
: br.getFieldViolationsList()) {
System.out.println("Field: "
+ fv.getField()
+ " – " + fv.getDescription());
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
}
}
Code: 3
Message: Invalid transfer request
Field: amount – Amount must be positive, got: -500.00
What are the standard detail types?
| Detail Type | Purpose |
|---|---|
| ErrorInfo | Reason string, domain, key-value metadata |
| BadRequest | Per-field violations for form validation |
| RetryInfo | Minimum delay before retrying |
| QuotaFailure | Which quota was exceeded |
| PreconditionFailure | Which precondition failed |
| ResourceInfo | Which resource was involved |
| DebugInfo | Stack trace (internal use only) |
What are common mistakes with gRPC status codes?
The first mistake is using INTERNAL as a catch-all for every error, which hides client errors that should be INVALID_ARGUMENT or NOT_FOUND. The second is leaking sensitive internal details in the status description.
// WRONG: INTERNAL for a client error
observer.onError(
Status.INTERNAL
.withDescription("Account not found")
.asRuntimeException());
// Should be NOT_FOUND, not INTERNAL
// WRONG: leaking internal details
observer.onError(
Status.INTERNAL
.withDescription("SQL error: ORA-12154 on db-prod-03")
.asRuntimeException());
// The client can read this!
// CORRECT: appropriate code, safe description
observer.onError(
Status.NOT_FOUND
.withDescription("Account SAV2001 not found")
.asRuntimeException());
What should a senior engineer know?
Use the standard detail types rather than inventing custom ones. Keep INTERNAL strictly for genuine server bugs. Never leak sensitive internal information in the description. Use ErrorInfo with a reason and domain so clients can handle errors programmatically without parsing message strings.
Visual Overview
┌───────────────────────────────────────────────────┐ │ gRPC Error Flow with Rich Details │ ├───────────────────────────────────────────────────┤ │ │ │ Client ──── TransferRequest ────→ Server │ │ (amount = -500) │ │ │ │ Server validates: │ │ │ │ │ ▼ │ │ amount <= 0 → Build google.rpc.Status │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────┐ │ │ │ google.rpc.Status │ │ │ │ code: INVALID_ARGUMENT (3) │ │ │ │ message: "Invalid transfer" │ │ │ │ details: │ │ │ │ └─ BadRequest │ │ │ │ └─ FieldViolation │ │ │ │ field: "amount" │ │ │ │ desc: "Must be > 0" │ │ │ └────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ Packed in HTTP/2 trailer ──→ Client │ │ │ │ Client: StatusProto.fromThrowable(e) │ │ → reads code, message, details │ │ → highlights "amount" field in UI │ └───────────────────────────────────────────────────┘
Key Takeaway
gRPC does not use HTTP status codes; it has its own small set of numeric status codes carried in the HTTP/2 trailer at the end of every call. Each code has a precise meaning: INVALID_ARGUMENT for a bad client value, NOT_FOUND for a missing entity, ALREADY_EXISTS for a create conflict, PERMISSION_DENIED when the caller is authenticated but not authorized, UNAVAILABLE for a transient outage, and INTERNAL for a genuine server bug.
Picking the right code is not cosmetic, because it drives client behavior, especially retries. UNAVAILABLE is typically safe to retry, but INVALID_ARGUMENT and NOT_FOUND must never be retried because they will always fail the same way. When a gRPC-Web or REST gateway sits in front, it maps gRPC codes to HTTP codes, so INVALID_ARGUMENT becomes 400, NOT_FOUND becomes 404, and UNAVAILABLE becomes 503.
A plain code and message are often not enough, so for machine-readable specifics you use the richer error model, which packs a google.rpc.Status into the trailer carrying the code, a message, and typed detail messages like ErrorInfo with a reason and metadata, BadRequest with per-field violations, and RetryInfo telling the client how long to wait. In Java you build these with the com.google.rpc types and convert with StatusProto.toStatusRuntimeException, and the client extracts them with StatusProto.fromThrowable.
In banking work at National Bank of India the transfer service reported failures this way. A non-positive amount returned INVALID_ARGUMENT with a BadRequest field violation so the mobile app could highlight the exact field, a missing account returned NOT_FOUND, and an over-limit transfer returned FAILED_PRECONDITION with an ErrorInfo carrying the available and requested amounts.
Remember one sentence: gRPC status codes drive retry behavior and rich error details via google.rpc.Status give clients both a category and actionable specifics without parsing message strings.
---