Testing gRPC Error Handling: Status Codes, Retries, and Deadlines

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 == 1

Key Testing Principles for gRPC Errors

  1. Always check the status code, not just that an error occurred
  2. Check error messages for useful debugging information
  3. Test rich error details if your service sends them
  4. Test deadline handling — pass context through all layers
  5. Test retry logic separately from business logic
  6. Never retry non-retryable codes — NOT_FOUND, INVALID_ARGUMENT, PERMISSION_DENIED are permanent

gRPC's structured error model is one of its advantages over REST. Use it — and test it thoroughly.

Read more

Start now free