Today's Question:  What does your personal desk look like?        GIVE A SHOUT

Go Error Best Practice

  sonic0002        2021-10-07 07:38:28       2,250        0    

Being indulged in Go for quite a while and having implemented web-related programs, grpc interfaces and Operators, I seem to be an advanced beginner now.

However, I am still a raw hand in production-environmental debugging, which is cumbersome if done by querying logs or error messages. Imagine the scenario that a full-text search is called when the specific location of the error log is missing. Then what happens when those error logs are not only in one place? Yes, my error logs can no longer help me locate the errors quickly and accurately.

Apparently, the lack of the try {…} catch structure supported by most other high-level languages makes Go’s design on the error handling controversial. And the following code structures are what occur everywhere in Go: each error needs to be processed, and errors are nested layer by layer. As a Java developer for years, you cannot escape but adapt.

a, err := fn()
if err != nil {
  return err
}func fn() error {
  b, err := fn1()
  if  err != nil {
    …
    return err
  }
  if _, err = fn2(); err != nil {
    …
  }
}

Nevertheless, we can always step on our predecessors’ shoulders and work out a solution to the difficulties. In the sections below, I am sharing some rules for error handling and some third-party packages that can do good to efficiency, which I summarized.

What is Go Error?

In Go, the error is just an interface.

type error interface {
  Error() string
}

So, implementing, creating, or throwing an error is actually to implement this interface. The three most common ways are

  • errors.New
  • fmt.Errorf
  • implement error interface
var ErrRecordNotExist   = errors.New("record not exist")
func ErrFileNotExist(filename string) error {
  return fmt.Errorf("%s file not exist", filename)
}
type ErrorCallFailed struct {
  Funcname string
}
func (*ErrorCallFailed) Error() string {
  return fmt.Sprintf(“call %s failed”, funcname)
}
var ErrGetFailed error = &ErrorCallFailed{ Funcname: "getName", }

And only the two following logics are involved in Go errors.
  • Throwing errors, which involves how we define the errors. When implementing functions, reasonable errors need to be returned for abnormal situations.
  • Handling errors. When calling a function, different logics should be implemented based on the return of the function, taking into account whether there is an error, whether the error is of a certain type, whether the error is to be ignored, etc.
func (d *YAMLToJSONDecoder) Decode(into interface{}) error {
	bytes, err := d.reader.Read()
	if err != nil && err != io.EOF {
		return err
	}

	if len(bytes) != 0 {
		err := yaml.Unmarshal(bytes, into)
		if err != nil {
			return YAMLSyntaxError{err}
		}
	}
	return err
}

type YAMLSyntaxError struct {
	err error
}

func (e YAMLSyntaxError) Error() string {
	return e.err.Error()
}

This piece from Kubernetes decode.go can not only directly return errors but wrap errors as well, either returning YAMLSyntaxError, or simply ignoring io.EOF.

Generally, there are three ways to determine the error type.

  • Directly by ==,if err == ErrRecordNotExist {}.
  • Type inference, if _, ok := err.(*ErrRecordNotExist); ok {}.
  • errors.Is and errors.As methods, which were added since Go 1.13. if errors.Is(err, ErrRecordNotExist)involves error wrap, tackling the trouble of locating the nested errors.

Rules To Follow

Upon understanding the basic concepts of Go errors, it’s time to discuss the rules that can be followed for better practice. Let’s do it from the definitions, then to errors handling.

Define The Error

  • Use fmt.Errorf over errors.New

fmt.Errorf provides splicing parameters function, and wraps errors. Though we see no difference in these two methods when dealing with a simple error, always set fmt.Errorf as your preference can keep the code uniform.

  • Encapsulate the similar errors

Encapsulating the same errors, such as theErrorCallFailed mentioned above, is a common code optimization, which can unwrap the layers when combined with errors.Is or errors.As, and better determine the real cause of the error. As to the difference between errors.Is and errors.As , the former needs both type matching and message matching, while the latter only requires type matching.

func fn(n string) error {
  if _, err := get(n); err != nil {
    return ErrorCallFailed("get n")
  }
}

func abc() error {
  _, err = fn("abc")
  if err != nil {
    return fmt.Errorf("handle abc failed, %w", err)
  }
}

func main() {
  _, err := abc()
  if errors.Is(err, ErrorCallFailed){
    log.Fatal("failed to call %v", err)
    os.Exist(1)
  }
}
  • Use %w Over %v

In order to obtain the complete call chain when a method is called by multiple places, developers will wrap layer by layer where the error is returned, and continuously add the unique features of the current call via fmt.Errorf which may be a log or a parameter. And the occasional adopting of %v instead of %w in error splicing will make Go’s error wrapping features ineffective in and since Go1.13. The error type after the correct wrapping is as follows.

  • Make error messages concise

Reasonable error messages can keep us from the redundant information with the layer-by-layer wrapping.

Many have the habit of printing logs in the below matter, adding the parameters, name of the current method, and the name of the calling method, which is unnecessary.

func Fn(id string) error {
  err := Fn1()
  if err != nil {
    return fmt.Errorf("Call Fn1 failed with id: %s, %w", id, err
  }
  ...
  return nil
}

However, a clear and straightforward error log only contains the information of the current operation error, internal parameters and actions, and information that is unknown to the caller, but not that the caller knows, such as the current method and parameters. Here is an error log of endpoints.go in Kubernetes, a very good example, printing only the internal Pod-related parameters and the failed action of Unable to get Pod.

func (e *Controller) addPod(obj interface{}) {
  pod := obj.(*v1.Pod)
  services, err := e.serviceSelectorCache.GetPodServiceMemberships(e.serviceLister, pod)
  if err != nil {
    utilruntime.HandleError(fmt.Errorf("Unable to get pod %s/%s's service memberships: %v", pod.Namespace, pod.Name, err))
    return
  }
  for key := range services {
    e.queue.AddAfter(key, e.endpointUpdatesBatchPeriod)
  }
}

Handle The Error

The golden five rules.

  • errors.Is is better than ==

== is relatively error-prone and can only compare the current error type but can not unwrap. Therefore, errors.Is or errors.As are better choices.

package main

import (
	"errors"
	"fmt"
)

type e1 struct{}

func (e e1) Error() string {
	return "e1 happended"
}

func main() {
	err1 := e1{}

	err2 := e2()

	if err1 == err2 {
		fmt.Println("Equality Operator: Both errors are equal")
	} else {
		fmt.Println("Equality Operator: Both errors are not equal")
	}

	if errors.Is(err2, err1) {
		fmt.Println("Is function: Both errors are equal")
	}
}

func e2() error {
	return fmt.Errorf("e2: %w", e1{})
}
// Output
Equality Operator: Both errors are not equal
Is function: Both errors are equal
  • Print error logs, but not normal logs
buf, err := json.Marshal(conf)
if err != nil {
  log.Printf(“could not marshal config: %v”, err)
}

A common mistake of novices is using log.Printf to print all logs, including the error log, which fails us to process logs correctly via log level, and toughs the debugging. And we can learn the right approach from dependencycheck.go , where log.Fatalf is applied.

if len(args) != 1 {
  log.Fatalf(“usage: dependencycheck <json-dep-file> (e.g. ‘go list -mod=vendor -test -deps -json ./vendor/…’)”)
}if *restrict == “” {
  log.Fatalf(“Must specify restricted regex pattern”)
}depsPattern, err := regexp.Compile(*restrict)if err != nil {
  log.Fatalf(“Error compiling restricted dependencies regex: %v”, err)
}
  • Never process logic by errors

Here’s an illustration of the wrong process.

bytes, err := d.reader.Read()
if err != nil && err != io.EOF {
  return err
}
row := db.QueryRow(“select name from user where id= ?”, 1)
err := row.Scan(&name)
if err != nil && err != sql.ErrNoRows{
  return err
}

As we can see, both the two errors of io.EOF and sql.ErrNoRows are neglected, and the latter is a typical example of using errors to represent business logic (data does not exist). I am against such design but supportive of the optimization of size, err:= row.Scan(&name) if size == 0 {log.Println(“no data”) } , assisting by adding a return parameter instead of throwing an error directly.

  • The bottom method returns errors, while the upper method handles the errors.
func Write(w io.Writer, buf []byte) error {
  _, err := w.Write(buf)
  if err != nil {
    log.Println(“unable to write:”, err)
    return err
  }
  return nil
}

The code similar to the above has an obvious problem. There are most likely duplicate logs if an error is returned after printing the log because the caller may also print the log.

Then how to avoid it? Let each method only perform one function. And a common choice here is that the bottom-level method only returns errors, and the upper-level method handles errors.

  • Wrap error messages and add context that is conducive to troubleshooting.

With no native stacktrace to rely on in Go, we can only get those abnormal stack information by self-implementation or third-party libraries. For example, Kubernetes implements a relatively complex klog package to support log printing, stack information, and context. And you refer to Structured logging in Kubernetes, if you develop Kubernetes-related applications, such as Operator. Besides, those third-party error encapsulation libraries, such as pkg/errors, are also very popular.

The End

The Go design philosophy has the original intention to simplify but sometimes complicates things. However, you can never regard Go error handling good-for-nothing, even though it is not so user-friendly. At least, the error-by-error return is a good design, handling the errors uniformly in the call place of the highest layer. Besides, we can still expect those improvements in the upcoming versions that will bring an easier application.

Thanks for reading!

Reference

Note: The post is authorized by original author to republish on our site. Original author is Stefanie Lai who is currently a Spotify engineer and lives in Stockholm, original post is published here.

ERROR HANDLING  GO ERROR 

Share on Facebook  Share on Twitter  Share on Weibo  Share on Reddit 

  RELATED


  0 COMMENT


No comment for this article.