Jun 30, 2024

Restoring Go Errors in gRPC Services

In my last post, I talked about the benefits of using interfaces in Go. In short, interfaces are a powerful tool to specify the behavior of objects. However, Go interfaces lack a mechanism to specify a predefined set of errors that can be returned to narrow the definition, such as using a throws keyword like in Swift or Java. An error returned by an interface could be any error. This is especially interesting when one implementation involves a network roundtrip that could introduce different errors than a location implementation.

When refactoring a production codebase, you don’t always have the budget or desire to rewrite the code consuming an interface. New implementations must match the previous logic down to the returned errors. Let’s see how this can be done when using gRPC.

Errors in Go

Go is built on conventions, including for handling errors. In Go, functions return errors alongside values. Errors can be wrapped and returned again, forming a chain of errors. This is quite nice as it helps you trace what went wrong, and where.

Below is an example demonstrating error chaining:

func getOneRow(sql string, args []any) (any, error) {
	res, err := db.Do(sql, args)
	if err != nil {
		return nil, fmt.Errorf("could not run command: %w", err)
	}
	return res, nil
}

func loadUser(userId string) (User, error) {
	user, err := accessDb("select * from user where userId = $1;", userId)
	if err != nil {
		return nil, fmt.Errorf("could not access database: %w", err)
	}
	return user, nil
}

func isAdmin(userId string) (bool, error) {
	user, err := loadUser
	if err != nil {
		return false, fmt.Errorf("could not load user: %w", err)
	}
	return user.isAdmin, nil
}

In some layer of the call hierarchy, internal errors are converted into business logic errors: Not finding a row in a database may be relevant for internal code, but a user cares about the fact that their account doesn’t exist because they’ve signed up with a different email address. User-facing errors can be added into the error chain and retrieved using errors.Is.

Errors over the network

All these conventions work as long as your code runs in a single process. When sending requests over a network using libraries like gRPC, traditional error handling conventions can be challenging due to differing error serialization methods. gRPC offers error codes and details for attaching rich information to errors, which is great when all consumers expect gRPC-style error handling. Sometimes, error chains are just converted to their string representation. Good luck checking a string for the existence of a specific error.

Depending on your use case, the fact that functions are invoked over the network shouldn’t be exposed to the downstream consumers of your APIs, though. When defining an interface like ImageCreator below, you specify expected behavior without detailing network-specific errors or concrete error types.

type ImageCreator interface {
	CreateImage(data CreateData) (Image, error)
}

type CreateData struct {
	Prompt string
}

type Image struct {
	URL url.URL
}

In other words, you can’t expect code consuming the ImageCreator interface to handle gRPC-specific errors.

Restoring Errors

Imagine we’re using the ImageCreator service outlined above to generate a header image for blog posts (how original).

func generateHeaderImage(creator ImageCreator) (url.URL, error) {
	img, err := creator.CreateImage(CreateData{"abstract blog post header"})
	if err != nil {
		if errors.Is(err, ErrInvalidPrompt) {
			showInvalidPromptDialog()
			return nil, nil
		}

		return nil, fmt.Errorf("something went wrong: %w", err)
	}

	return img.URL, nil
}

To inform a user about passing an invalid prompt, we define the following error

var ErrInvalidPrompt  = fmt.Errorf("invalid prompt")

Consider two concrete implementations: A local service running in the same process, and a remote version running on beefier machines, communicating via gRPC.

Both implementations can return this error, but it might be hidden in a chain of errors

could not create image: invalid prompt
could not send create image request: could not create image: invalid prompt

Both of these are completely valid, and we want to surface them to the user with a proper error dialog! By using errors.Is() in the client implementation, we can traverse the error chain until we find the quota exceeded error. For the server, it won’t be this easy. Remember that most implementations simply return a string! So what can we do here?

Most implementations use error codes for retaining application-specific context. A strategy involves detecting user-facing errors before returning them, transforming them into a network representation and back to a concrete error on the client.

Below is an example implementation of this strategy.

Common

var userFacingErrors := []error{
		ErrQuotaExceeded,
}

First, we define a slice of known errors. You could add any user-facing error here.

Server

func preProcessError(err error) error {
	for _, ufe := range userFacingErrors {
		if errors.Is(err, ufe) {
			return ufe
		}
	}

	return err
}

// CreateImage implements imagecreator.CreateImage
func (s *server) CreateImage(ctx context.Context, in *pb.CreateImageRequest) (*pb.CreateImageReply, error) {
	img, err := createImageOnServer(in)
	if err != nil {
		return nil, status.New(codes.InvalidArgument, preProcessError(err))
	}

	return &pb.CreateImageReply{Image: img}, nil
}

preProcessError will iterate over a range of all known user-facing errors and, if included in the given error, return only the known error. This will shorten error chains and return only a single known error.

Client

func restoreError(reason string) error {
	for _, err := range userFacingErrors {
		if err.Error() == reason {
			return err
		}
	}

	return nil
}

type imageCreatorClient {}

func (i *imageCreatorClient) CreateImage(data CreateData) (Image, error) {
	c := pb.NewGreeterClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.CreateImage(ctx, &pb.CreateImageRequest{data})
	if err != nil {
		s := status.Convert(err)
		if restored := restoreError(s.Message()); restored != nil {
			return Image{}, restored
		}

		return Image{}, fmt.Errorf("something went wrong")
	}

	return r.Image, nil
}

The client extends the error handling approach started by preProcessError by verifying whether the message included in the gRPC status matches any known error. If this lines up, a proper Go error will be returned, which can then be checked by downstream consumers.


Restoring errors is an easy method to hide implementation details in scenarios where requests are sent over a network. This way, consumers can interact with interfaces as intended. Improved support for narrowing error types in future Go releases would be beneficial for building robust error handling. Until then, documentation is the best we can do to ensure our code behaves as expected.