Back to Advanced Topics
Q32Question 32 of 41

What are gRPC status codes, how do they map to HTTP codes, and how do you return rich error details?

Key Concepts
  • gRPC has its own numeric status codes carried in the HTTP/2 trailer, separate from HTTP status codes.
  • Choosing the right gRPC status code drives client behavior, especially retries: UNAVAILABLE is retryable, INVALID_ARGUMENT is not.
  • When a gRPC-Web or REST gateway sits in front, it maps gRPC codes to HTTP codes (INVALID_ARGUMENT to 400, NOT_FOUND to 404, UNAVAILABLE to 503).
  • Rich error details use google.rpc.Status with typed detail messages like ErrorInfo, BadRequest, and RetryInfo packed in the trailer.
  • In Java, build rich errors with com.google.rpc types and StatusProto.toStatusRuntimeException, and parse them with StatusProto.fromThrowable.
  • Keep INTERNAL for genuine server bugs so it does not become a catch-all that hides client errors.
  • Never leak sensitive internal information in the gRPC status description because the client can read it.

Definition

gRPC has its own status codes in the trailer, gateways map them to HTTP codes, and rich errors attach typed details via google.rpc.Status so clients get both a category and actionable specifics.
  • 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), and INTERNAL (13).
  • HTTP mapping – Gateways translate gRPC codes to HTTP codes for REST clients: INVALID_ARGUMENT to 400, NOT_FOUND to 404, PERMISSION_DENIED to 403, UNAVAILABLE to 503.
  • Rich error model – google.rpc.Status message carrying the code, a message, and typed detail messages like ErrorInfo, BadRequest, and RetryInfo packed into the trailer.
  • StatusProto – Java utility class to convert between google.rpc.Status and StatusRuntimeException for 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 |

JAVA
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.

JAVA
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.

JAVA
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().

JAVA
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();
                    }
                }
            }
        }
    }
}
TEXT
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.

JAVA
// 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

DIAGRAM
┌───────────────────────────────────────────────────┐
│         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.

---

Share
Use ← → arrow keys to navigate