We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.

This lesson's interactive features are locked, please to keep using them

Handle Errors Once

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?

Error Context

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
}

Assignment

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.