Error Handling in Golang
Sebastian Pawlaczyk
Error Handling in Golang
Fault tolerance is essential for any application, and effective error handling plays a critical role in achieving it. By being diligent in error handling, we gain significant advantages in debugging and maintenance processes. In this article, I will explore Golang’s approach to error handling, focusing on best practices and highlighting potential improvements suggested by the Golang community.
Explicitly Defined
When transitioning from other languages to Golang, one key difference is the shift from a try/catch approach to returning errors as regular values from functions. This method explicitly indicates where a problem occurs and requires developers to handle it immediately after each function call. This design ensures that issues are always visible and must be addressed, helping to prevent unhandled exceptions.
Error Abstraction
Golang introduces an error interface used to represent errors in a standardized way.
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
In most cases, developers use errors that are already implemented in github.com/pkg/errors. Here’s an example of how a simple error is constructed using this package:
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
Error Handling
There are two primary methods for handling errors in Golang:
- Graceful Handling: This involves logging and returning the error value. It is used in most cases where we need to verify our business logic and allow the application to continue running.
func doAction() error {
return errors.New("doAction failed")
}
func main() {
if err := doAction(); err != nil {
fmt.Printf("program executed with err: %s", err.Error())
return
}
fmt.Println("program executed successfully")
}
- Panic: This approach stops the current goroutine immediately. It is mostly used during the startup of an application when initializing services or database instances. The recover function is used to handle a panic, enabling cleanup or other necessary actions for a smooth teardown.
type Repository interface{}
type repository struct{}
func NewRepository() (Repository, error) {
...
if !isClientAvailable {
return nil, errors.New("database client is not available")
}
return repository{}, nil
}
func main() {
defer func() {
if rec := recover(); rec != nil {
fmt.Printf("panic is received: %v", rec)
// safe cleanup & necessary action
}
}()
repository, err := NewRepository()
if err != nil {
panic(fmt.Sprintf("repository cannot be initialized: %s", err.Error()))
}
...
}
Wrapping
Golang provides the fmt.Errorf function with the %w verb to wrap errors. This feature preserves the original error while adding additional context, making it easier to trace the error’s origin when it propagates to higher levels in the call stack.
func loadConfig() error {
return errors.New("missing config values")
}
func doAction() error {
err := loadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
return nil
}
Best Practices
1. Add Context to Errors: Always attach meaningful context when propagating errors to help diagnose issues.
if err != nil {
return nil, fmt.Errorf("could not connect to database %s: %w", dbName, err)
}
2. Log Errors Where Appropriate: Log errors at the boundaries of the application, such as at the API level or entry points. Avoid logging errors excessively at every level, as this can lead to redundant and noisy logs.
func handler() error {
...
if err := service(); err != nil {
fmt.Printf("can not process request, due to: %s", err.Error())
return err
}
...
}
func service() error {
...
if err := repository(); err != nil {
return fmt.Errorf("repository problem: %s", err.Error())
}
...
}
func repository() error {
...
return errors.New("database is not responding")
}
3. Avoid Silent Failures: Never ignore errors silently. Even if an error is expected and doesn’t require action, consider logging it or handling it explicitly to maintain transparency.
func doAction() error {
return errors.New("doAction failed")
}
func main() {
// wrong practice
_ = doAction()
fmt.Println("program executed successfully")
}
4. Use Custom Error Types: For complex systems, define custom error types that implement the error interface. This allows you to add additional methods or fields for better error categorization and handling.
type ServerError struct {
Message string
Code string
HttpResponseCode int
}
func (err ServerError) Error() string {
return err.Code + ": " + err.Message
}
var (
DecodeRequestDataError = ServerError{
Message: "Could not decode the request data - please verify the correctness of the request.",
Code: "001",
HttpResponseCode: http.StatusBadRequest,
}
)
Limitations and Future Directions
Once we achieve perfection, it can soon become boring. Therefore, I am happy to highlight some areas with room for improvement in current approach:
1. Verbosity:
The explicit nature of error handling in Golang often leads to repetitive code. Developers need to check and handle errors after every function call that returns an error. This can result in boilerplate code, making functions verbose and harder to read.
func main() {
val, err := func1()
if err != nil {
// handle error
return
}
val, err = func2()
if err != nil {
// handle error
return
}
...
}
Potentail Solution:
A proposal for a try built-in function aims to reduce verbosity by simplifying error handling. This function would take a single expression as an argument, evaluating it for errors and returning the result if no error is present.
f, err := os.Open(filename)
if err != nil {
return …, err // zero values for other results, if any
}
can be simplified to
f := try(os.Open(filename))
Source: Try Build-in
2. Lack of Stack Traces and Context:
- By default, errors in Golang do not include stack traces. This makes it challenging to diagnose where an error originated, especially in complex applications.
- Basic error messages often lack context about the application state when the error occurred, which can hinder effective debugging.
Potential Solutions:
- Using third-party libraries, such as github.com/pkg/errors, to enhance error messages with stack trace information. This library, while not part of the official Golang standard library, is widely adopted by the community for improved error handling.
- Implementing structured logging to capture more detailed information about errors and application state at the time of the error.
Conclusions
From my personal experience, Golang’s approach to error handling is straightforward and easy to maintain. While verbosity and a lack of built-in context can be challenges, these issues are minor compared to the benefits of clarity and transparency. This explicit handling ensures that errors are promptly identified and managed, ultimately leading to more reliable and maintainable code.