Testing gRPC Error Handling: Status Codes, Retries, and Deadlines
gRPC has a well-defined set of status codes that replace HTTP status codes. Testing error handling means testing that your service sets the right codes, includes useful error details, and that your clients handle them correctly — including retries and deadline logic.
The gRPC Status Code Set
Unlike HTTP's 70+ status codes, gRPC defines 16. The most important for testing:
| Code | When to use |
|---|---|
OK |
Successful completion |
NOT_FOUND |
Resource doesn't exist |
ALREADY_EXISTS |
Trying to create a duplicate |
INVALID_ARGUMENT |
Bad request data |
UNAUTHENTICATED |
Missing or invalid credentials |
PERMISSION_DENIED |
Authenticated but not authorized |
UNAVAILABLE |
Service temporarily unavailable (retryable) |
DEADLINE_EXCEEDED |
Operation timed out |
INTERNAL |
Unexpected server error |
The most common mistake: using INTERNAL for everything. NOT_FOUND and INVALID_ARGUMENT are specific enough to test against.
Testing Status Codes in Go
// service.go
func (s *UserServiceServer) GetUser(
ctx context.Context,
req *pb.GetUserRequest,
) (*pb.UserResponse, error) {
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user_id is required")
}
user, err := s.repo.FindByID(ctx, req.UserId)
if err == ErrNotFound {
return nil, status.Errorf(codes.NotFound, "user %s not found", req.UserId)
}
if err != nil {
return nil, status.Error(codes.Internal, "internal server error")
}
return &pb.UserResponse{Id: user.ID, Email: user.Email}, nil
}// service_test.go
func TestGetUser_EmptyID_ReturnsInvalidArgument(t *testing.T) {
svc := NewUserServiceServer(&FakeUserRepo{})
_, err := svc.GetUser(context.Background(), &pb.GetUserRequest{UserId: ""})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "user_id is required")
}
func TestGetUser_MissingUser_ReturnsNotFound(t *testing.T) {
repo := &FakeUserRepo{} // empty — no users stored
svc := NewUserServiceServer(repo)
_, err := svc.GetUser(context.Background(), &pb.GetUserRequest{UserId: "u999"})
st, _ := status.FromError(err)
assert.Equal(t, codes.NotFound, st.Code())
assert.Contains(t, st.Message(), "u999")
}status.FromError extracts the status from a gRPC error. Always check that the second return value is true — if it's false, the error isn't a gRPC status error, which is itself a bug.
Rich Error Details
gRPC supports structured error details beyond a status code and message. Test that you're sending them.
// Sending rich error details
import "google.golang.org/grpc/status"
import "google.golang.org/grpc/codes"
import errdetails "google.golang.org/genproto/googleapis/rpc/errdetails"
func (s *UserServiceServer) CreateUser(
ctx context.Context,
req *pb.CreateUserRequest,
) (*pb.UserResponse, error) {
var violations []*errdetails.BadRequest_FieldViolation
if req.Email == "" {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "email",
Description: "email is required",
})
}
if !isValidEmail(req.Email) {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "email",
Description: "email format is invalid",
})
}
if len(violations) > 0 {
st, _ := status.New(codes.InvalidArgument, "validation failed").
WithDetails(&errdetails.BadRequest{FieldViolations: violations})
return nil, st.Err()
}
// proceed...
}func TestCreateUser_InvalidEmail_IncludesFieldViolation(t *testing.T) {
svc := NewUserServiceServer(&FakeUserRepo{})
_, err := svc.CreateUser(context.Background(), &pb.CreateUserRequest{
Email: "not-an-email",
})
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.InvalidArgument, st.Code())
// Check rich error details
for _, detail := range st.Details() {
if br, ok := detail.(*errdetails.BadRequest); ok {
violations := br.GetFieldViolations()
require.Len(t, violations, 1)
assert.Equal(t, "email", violations[0].GetField())
return
}
}
t.Fatal("expected BadRequest detail not found")
}Testing Deadline Exceeded
Deadlines (timeouts) are a first-class concept in gRPC. Test that your service respects them and your client handles DEADLINE_EXCEEDED.
// Test that client properly handles deadline exceeded
func TestGetUser_DeadlineExceeded(t *testing.T) {
// Set up a slow server
slowRepo := &SlowFakeUserRepo{delay: 100 * time.Millisecond}
svc := NewUserServiceServer(slowRepo)
// Create a context with a very short deadline
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := svc.GetUser(ctx, &pb.GetUserRequest{UserId: "u1"})
// Should get deadline exceeded, not a result
st, ok := status.FromError(err)
if ok {
assert.Equal(t, codes.DeadlineExceeded, st.Code())
} else {
// Context error maps to deadline exceeded
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
}
// In service implementation, respect context cancellation:
func (s *UserServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserResponse, error) {
// Check context before slow operations
if err := ctx.Err(); err != nil {
return nil, status.FromContextError(err).Err()
}
user, err := s.repo.FindByID(ctx, req.UserId) // pass context through
// ...
}Java: Testing with Mockito and InProcess Channel
In Java, use gRPC's InProcessChannelBuilder for fast, in-process integration tests.
@ExtendWith(GrpcExtension.class)
class UserServiceTest {
@GrpcMockBean(UserServiceGrpc.class)
private UserServiceImplBase mockService;
@GrpcClient("inprocess")
private UserServiceBlockingStub blockingStub;
@Test
void returnsNotFoundForMissingUser() {
doAnswer(invocation -> {
StreamObserver<UserResponse> observer = invocation.getArgument(1);
observer.onError(Status.NOT_FOUND
.withDescription("user u999 not found")
.asRuntimeException());
return null;
}).when(mockService).getUser(any(), any());
StatusRuntimeException thrown = assertThrows(
StatusRuntimeException.class,
() -> blockingStub.getUser(GetUserRequest.newBuilder().setUserId("u999").build())
);
assertEquals(Status.Code.NOT_FOUND, thrown.getStatus().getCode());
assertTrue(thrown.getStatus().getDescription().contains("u999"));
}
}Testing Retry Logic in the Client
Clients should retry on UNAVAILABLE (transient failures) but not on NOT_FOUND (permanent failures).
# Python client with retry
import grpc
from grpc import StatusCode
import time
def get_user_with_retry(stub, user_id, max_retries=3, backoff=0.1):
RETRYABLE_CODES = {StatusCode.UNAVAILABLE, StatusCode.RESOURCE_EXHAUSTED}
for attempt in range(max_retries):
try:
return stub.GetUser(user_pb2.GetUserRequest(user_id=user_id))
except grpc.RpcError as e:
if e.code() not in RETRYABLE_CODES:
raise # Don't retry permanent errors
if attempt == max_retries - 1:
raise
time.sleep(backoff * (2 ** attempt)) # exponential backoff
# Test the retry logic
def test_retries_on_unavailable_then_succeeds():
mock_stub = MagicMock()
unavailable_error = grpc.RpcError()
unavailable_error.code = lambda: grpc.StatusCode.UNAVAILABLE
success_response = user_pb2.UserResponse(id='u1', email='alice@example.com')
# Fail twice, then succeed
mock_stub.GetUser.side_effect = [
unavailable_error,
unavailable_error,
success_response
]
result = get_user_with_retry(mock_stub, 'u1', max_retries=3, backoff=0)
assert result.id == 'u1'
assert mock_stub.GetUser.call_count == 3
def test_does_not_retry_on_not_found():
mock_stub = MagicMock()
not_found_error = grpc.RpcError()
not_found_error.code = lambda: grpc.StatusCode.NOT_FOUND
mock_stub.GetUser.side_effect = not_found_error
with pytest.raises(grpc.RpcError):
get_user_with_retry(mock_stub, 'nobody', max_retries=3, backoff=0)
# Should have only tried once
assert mock_stub.GetUser.call_count == 1Key Testing Principles for gRPC Errors
- Always check the status code, not just that an error occurred
- Check error messages for useful debugging information
- Test rich error details if your service sends them
- Test deadline handling — pass context through all layers
- Test retry logic separately from business logic
- Never retry non-retryable codes —
NOT_FOUND,INVALID_ARGUMENT,PERMISSION_DENIEDare permanent
gRPC's structured error model is one of its advantages over REST. Use it — and test it thoroughly.