Skip to content

varunbpatil/protoc-gen-go-errors

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

protoc-gen-go-errors

What is it?

A protoc plugin to generate idiomatic Go error types inspired by thiserror. Also includes an optional Result type inspired by Rust's Result.

Why?

You've probably written code like this:

type UserRepository interface {
    CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error)
    // ...
}

Looking at this, can you tell what possible errors CreateUser returns? Neither can I.

If you have a good development and review process, you can expect to see something like this:

// CreateUser persists a user in the repository.
+// Returns *InvalidArgument if the user details are invalid.
+// Returns *AlreadyExists if the user already exists.
func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error) {
    // ...
}

Good, but still not confidence inspiring. You need to look at the code and hope that the developer documented the possible errors and that the documentation is relevant.

You would then use the errors like this:

import (
    "google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type UserService struct {
    repo UserRepository
}

func (s *UserService) CreateUser(ctx context.Context, r *userpb.CreateUserRequest) (*userpb.CreateUserResponse, error) {
    // ...
    
    user, err := s.repo.CreateUser(ctx, &CreateUserRequest{...})
    if err != nil {
+        var invalidArgErr *InvalidArgument
+        if errors.As(err, &invalidArgErr) {
+            return nil, status.Error(codes.InvalidArgument, invalidArgErr.Error())
+        }
+
+        var alreadyExistsErr *AlreadyExists
+        if errors.As(err, &alreadyExistsErr) {
+            return nil, status.Error(codes.AlreadyExists, alreadyExistsErr.Error())
+        }
+
+        return nil, status.Error(codes.Internal, "something went wrong")
    }
}

And then somebody comes along and makes this change:

// CreateUser persists a user in the repository.
// Returns *InvalidArgument if the user details are invalid.
// Returns *AlreadyExists if the user already exists.
func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error) {
    // ...

+    // Check preconditions.
+    if err := checkPreconditions(); err != nil {
+        return errors.New("user cannot be created because preconditions were not satisfied. %w", err)
+    }
}

Now, a bunch of clients are unhappy because they received a generic "something went wrong" error message instead of an error telling them that some preconditions were not satisfied.

Is there a better way?

What if you could write code this this:

type UserRepository interface {
-    CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error)
+    CreateUser(ctx context.Context, r *CreateUserRequest) Result[*CreateUserResponse, *userpb.UserRepositoryError]
    // ...
}

Here, userpb.UserRepositoryError is a Go error type generated by the protoc-gen-go-errors protoc plugin and looks something like this:

type UserRepositoryError struct {
	state protoimpl.MessageState `protogen:"open.v1"`
	// Types that are valid to be assigned to Kind:
	//
	//	*UserRepositoryError_Invalid
	//	*UserRepositoryError_AlreadyExists
	//	*UserRepositoryError_DependenciesNotMet
	//	*UserRepositoryError_Other
	Kind          isUserRepositoryError_Kind `protobuf_oneof:"kind"`
	unknownFields protoimpl.UnknownFields
	sizeCache     protoimpl.SizeCache
}

Your gRPC handler would look like this:

func (s *UserService) CreateUser(ctx context.Context, r *userpb.CreateUserRequest) (*userpb.CreateUserResponse, error) {
    // ...
    
-    user, err := s.repo.CreateUser(ctx, &CreateUserRequest{...})
-    if err != nil {
-        var invalidArgErr *InvalidArgument
-        if errors.As(err, &invalidArgErr) {
-            return nil, status.Error(codes.InvalidArgument, invalidArgErr.Error())
-        }
-
-        var alreadyExistsErr *AlreadyExists
-        if errors.As(err, &alreadyExistsErr) {
-            return nil, status.Error(codes.AlreadyExists, alreadyExistsErr.Error())
-        }
-
-        return nil, status.Error(codes.Internal, "something went wrong")
-    }

+    resp := s.repo.CreateUser(ctx, &CreateUserRequest{...})
+    if resp.IsErr() {
+        switch v := e.Kind.(type) {
+            case *UserRepositoryError_Invalid:
+                return nil, status.Error(codes.InvalidArgument, v.Invalid.Error())
+            case *UserRepositoryError_AlreadyExists:
+                return nil, status.Error(codes.AlreadyExists, v.AlreadyExists.Error())
+            case *UserRepositoryError_DependenciesNotMet:
+                return nil, status.Error(codes.FailedPrecondition, v.DependenciesNotMet.Error())
+            case *UserRepositoryError_Other:
+                return nil, status.Error(codes.Internal, v.Other.Error())
+        }
+    }
}

where the repository would be implemented as:

func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) Result[*CreateUserResponse, *userpb.UserRepositoryError] {
    // ...

    // Check preconditions.
    if err := checkPreconditions(); err != nil {
-        return errors.New("user cannot be created because preconditions were not satisfied. %w", err)
+        return Result[*CreateUserResponse].Err(
+            new(userpb.UserRepositoryError).From(err),
+        )
    }

+    // All good. Return the response.
+    return Result[*CreateUserResponse, *userpb.UserRepositoryError].Ok(
+        &CreateUserReponse{...}
+    )
}

func checkPreconditions() *userpb.DependenciesNotMetError {
    // ...
}

Arguably, the returns are more verbose, but this is Go we're talking about. The increased verbosity might be worth it at least for key interfaces like the domain interface above. Domain interfaces are the contract with the rest of the application and errors should be part of that contract.

You don't have to use the Result type wrapper if you don't like it. The error types generated by this protoc plugin are still valid and idiomatic Go errors and you can use them anywhere you use errors.

How do you define errors?

Note

Only messages that end with the string "Error" are considered as errors by the protoc-gen-go-errors plugin.

For the example above, you would define errors like this:

// Top level domain error. Can be one of a fixed set of error types.
message UserRepositoryError {
    oneof kind {
        InvalidArgumentError invalid = 1;
        AlreadyExistsError already_exists = 2;
        DependenciesNotMetError dependencies_not_met = 3;
        OtherError other = 4;
    }
}

message InvalidArgumentError {
    option (errors.display) = "invalid argument: {key} = {value}";
    string key = 1;
    string value = 2;
}

message AlreadyExistsError {
    option (errors.display) = "id {id} already exists";
    string id = 1;
}

message DependenciesNotMetError {
    option (errors.display) = "the following dependencies were not met: {messages}";
    repeated string messages = 1;
}

message OtherError {
    option (errors.display) = "{message}";
    string message = 1
}

All errors in a single place makes it easy to audit errors to make sure we're not leaking sensitive information via errors.

There is also support for nested errors. Unwrap() can be used to get the nested error just as if the error was created with fmt.Errorf("... %w", err).

message IOError {
    option (errors.display) = "could not read {path}: {cause}";
    string path = 1;
    NotFoundError cause = 2;
}

message NotFoundError {
    option (errors.display) = "not found: {entity}";
    string entity = 1;
}

What code is generated?

The protoc-gen-go plugin takes care of generating the Go structs. The protoc-gen-go-errors plugin generates the Error() and Unwrap() methods that converts those Go structs into valid Go errors.

Show me a full example

The example directory contains a sample Go project with errors generated using this plugin. It shows how you can install and use the protoc-gen-go-errors plugin.

Inspired by

License

This project is licensed under the terms of the MIT license.

About

thiserror and Result (Rust) inspired errors in Go

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published