

0 / 2 embers
0 / 3000 xp
click for more info
Complete a lesson to start your streak
click for more info
Difficulty: 7
click for more info
Not enough gems
Cost: 6 gems
1: Logging Errors
incomplete
2: Stack Traces
incomplete
3: Slog Groups
incomplete
4: Handle Errors Once
incomplete
5: Adding Attributes
incomplete
6: Multiple Errors
incomplete
Back
ctrl+,
Next
ctrl+.
This lesson's interactive features are locked, please to keep using them
Dave Cheney, who developed the github.com/pkg/errors package, wrote a great blog post about error handling and logging called Don't just check errors, handle them gracefully. One section in particular has stuck with me (paraphrased here):
You should handle errors only once. Handling an error means inspecting the error value, and making a decision. If you make less than one decision, you're ignoring the error. But making more than one decision in response to a single error can also be problematic.
Consider the following example:
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
// annotated error goes to log file
log.Println("unable to write:", err)
// unannotated error returned to caller
return err
}
return nil
}
In this function, if an error occurs during Write, a line is written to a log file. Then the same error is returned to the caller, who may log it and return it again all the way up the call stack.
How many times will the one error be logged? It's difficult to say!
This is often called the "log-and-rethrow" pattern, at least in languages that use exceptions.
So, how do we handle errors once?
Since Go 1.13 (or earlier, with github.com/pkg/errors), it's possible to add further context to an existing error. The fmt.Errorf function has a %w verb that lets us wrap an error with additional context in a way that can be unwrapped later.
Say we have this problematic code that handles the same error twice, once with more detailed information (validatePurchase) and once with less (order):
func order(purchase Purchase) {
if err := validatePurchase(purchase); err != nil {
slog.Error("Failed to validate purchase", "error", err)
return
}
// happy path...
}
func validatePurchase(purchase Purchase) error {
for i, item := range purchase.Items {
if err := validateItem(item); err != nil {
slog.Error("Failed to validate item", "item_no", i, "item", item, "error", err)
return err
}
}
return nil
}
The better approach is to instead handle the error only once in the parent function (order), while still adding information to the error from the child function (validatePurchase) using fmt.Errorf.
func order(purchase Purchase) {
if err := validatePurchase(purchase); err != nil {
slog.Error("Failed to validate purchase", "error", err)
return
}
// happy path...
}
func validatePurchase(purchase Purchase) error {
for i, item := range purchase.Items {
if err := validateItem(item); err != nil {
return fmt.Errorf("failed to validate item %d (%v): %w", i, item, err)
}
}
return nil
}
Take a look at store.Lookup in internal/store/store.go. It currently logs a read error and returns it – yuck:
s.logger.Error("failed to read", "path", filepath.Join(s.dir, short), "error", err)
return "", err
Now the error is handled once in handlerRedirect, and the path context from the store is carried along inside the wrapped error rather than in a separate log line.
Restart your server with LINKO_LOG_FILE=linko.access.log set:
LINKO_LOG_FILE=linko.access.log go run .
Run and submit the CLI tests from the root of the Linko repo.