Published on 11/9/2022
Go errors are easy to ignore
A few junior Go programmers get confused about the different flavours of “ignoring Go errors” (e.g., a, b, c). In fact, more experienced software engineers mean different things when they say an error should be handled appropriately. “Appropriately” does a lot of heavy lifting here.
How do Go programmers ignore errors?
Do not acknowledge a returned error at all
func ChangeUserStuff(ctx context.Context, stuffID string) (*Stuff, error) {
// some logic that changes stuff
repository.SaveStuff(ctx, stuff)
}
The Go compiler does not force you to acknowledge and handle return values if you call the function without assigning at least one of the return values to a variable. So, the above code is valid and will compile and run in your application if you write it. The problem is that you are not handling the returned error at all.
One scenario where it can happen is when SaveStuff
takes care of error logging in its implementation, and the error does not affect the program’s critical path. This can happen with non-critical event publishing. If there’s an error, you still want to keep going. Indeed, not all errors mean the overarching operation failed. For these cases, simply ensure that the function does not return an error
at all in its signature. This is the best signal to let a programmer know they should not bother dealing with failures of this function.
Ignore a returned error value with a blank identifier
func GetUserStuff(ctx context.Context, stuffID string) (*Stuff, error) {
stuff, _ := repository.LoadStuffFromS3(ctx, id)
return stuff, nil
}
The second return value is an error
, but, for whatever reason, the programmer decides that they do not care about it and carry on with the happy path. In such a scenario, the returned stuff
may be a default value that allows the procedure to continue. This can be a reasonable approach, but we would still need to log that an error happened.
Mechanically return the error as it is
if err := repository.SaveStuff(ctx, stuff); err != nil {
return nil, err
}
Some argue, in this instance, that the error has been handled. They may be right. It all depends on what error is returned in SaveStuff
. Generally, we would want the error wrapped to give context to the return err
chain. If SaveStuff
is called by multiple functions within your codebase, wrapping it will help you immediately spot which instance caused the issue.
Is simply returning an error enough to handle it?
Yes and no. Not all programs are created equal in size, scope, and complexity.
For smaller tools and microservices, passing errors up the tree can be good enough, especially if the first error has enough information and context added to it to debug it down the line. Adding context within key procedures is very useful for more extensive applications and services with more logic and layers.
So, how should an error be handled?
In my experience, Go developers ensure proper handling by doing one or a combination of the following.
- wrap the error to add meaningful context
- use logging and telemetry
- fallback to a retry flow
Error Branching
A function which calls another function and receives an error may need to do different things based on the type of error it received.
The most famous example of this concept is what codebases do with sql errors. For instance, if a query return an error, you often check for sql.ErrNoRows
. Some return nil, nil
whilst others return []User{}, nil
, and finally, some will return nil, domain.NewNotFoundErr()
. If the error is not sql.ErrNoRows
, you generally fail more clearly and return nil, domain.NewDatabaseFailure(err)
.
The errors.Is
and errors.As
functions, introduced in Go 1.13, are useful for checking and converting errors. They provide a way to work with wrapped errors and to check for specific error conditions.
Wrapping Errors
Wrapping errors with additional context is a common practice in Go. This can be done using the fmt.Errorf
function or specialised error-wrapping libraries. By doing so, developers can trace the origin of errors more efficiently and provide more informative error messages.
Furthermore, wrapping errors allows for error propagation that helps maintain a clear trail of where the error originated and how it bubbled up through the application layers. This is particularly useful in larger codebases or systems where an error might pass through several layers of processing before reaching a point where it can be appropriately handled. By including context at each layer, developers can gain a more comprehensive understanding of the error’s cause and effect within the system.
This practice contributes to more maintainable and debuggable code, aiding in quicker resolution of issues and more robust error handling strategies. Over time, as the code evolves, having a well-defined error wrapping and propagation strategy ensures that the system remains agile to changes while keeping error handling robust and meaningful.
Logging and Telemetry
Logging errors and employing telemetry are valuable for monitoring and debugging. They provide insights into how often errors occur, under what circumstances, and how they impact the system. This data can be instrumental in both resolving current issues and anticipating potential future errors.
Retry Flow
Implementing a retry flow can help in scenarios where operations fail transiently, and there’s a good chance that retrying the operation might succeed. Simple libraries such as ”github.com/cenkalti/backoff” provide mechanisms to implement exponential backoff and retry logic.
In other instances, the error is really not critical and you may simply want to log or ignore it. For example, if you use a logging library that somehow fails, you do not want all incoming requests to fail simply because you cannot log debug messages in some places.
Centralised Error Handling
Centralising error handling can also be beneficial. By handling errors in a centralised manner, you can reduce code duplication and ensure that errors are handled consistently across your application.
Creating custom error types specific to your business domain provides more structured error handling, allowing different errors to be handled differently. This can also make it easier to add additional information to errors.
Handling Errors at the Outer Layer
It’s often advisable to handle errors at the outer layer of your program, allowing errors to bubble up to a level where they can be handled appropriately rather than at the point of occurrence. This avoids duplication of code, too.
Handling errors at the outer layer promotes a cleaner codebase. It separates error handling from the main business logic, making the code easier to understand and maintain. Centralized error analysis allows for a consistent approach to logging and reporting, aiding in quick issue resolution. This approach also facilitates better user experience by providing clear and informative error messages or fallback solutions.