parent
78a5df1684
commit
7ab3b7ef36
@ -0,0 +1,227 @@ |
|||||||
|
--- |
||||||
|
title: >- |
||||||
|
A Simple Rule for Better Errors |
||||||
|
description: >- |
||||||
|
...and some examples of the rule in action. |
||||||
|
tags: tech |
||||||
|
--- |
||||||
|
|
||||||
|
This post will describe a simple rule for writing error messages that I've |
||||||
|
been using for some time and have found to be worthwhile. Using this rule I can |
||||||
|
be sure that my errors are propagated upwards with everything needed to debug |
||||||
|
problems, while not containing tons of extraneous or duplicate information. |
||||||
|
|
||||||
|
This rule is not specific to any particular language, pattern of error |
||||||
|
propagation (e.g. exceptions, signals, simple strings), or method of embedding |
||||||
|
information in errors (e.g. key/value pairs, formatted strings). |
||||||
|
|
||||||
|
I do not claim to have invented this system, I'm just describing it. |
||||||
|
|
||||||
|
## The Rule |
||||||
|
|
||||||
|
Without more ado, here's the rule: |
||||||
|
|
||||||
|
> A function sending back an error should not include information the caller |
||||||
|
> could already know. |
||||||
|
|
||||||
|
Pretty simple, really, but the best rules are. Keeping to this rule will result |
||||||
|
in error messages which, once propagated up to their final destination (usually |
||||||
|
some kind of logger), will contain only the information relevant to the error |
||||||
|
itself, with minimal duplication. |
||||||
|
|
||||||
|
The reason this rule works in tandem with good encapsulation of function |
||||||
|
behavior. The caller of a function knows only the inputs to the function and, in |
||||||
|
general terms, what the function is going to do with those inputs. If the |
||||||
|
returned error only includes information outside of those two things then the |
||||||
|
caller knows everything it needs to know about the error, and can continue on to |
||||||
|
propagate that error up the stack (with more information tacked on if necessary) |
||||||
|
or handle it in some other way. |
||||||
|
|
||||||
|
## Examples |
||||||
|
|
||||||
|
(For examples I'll use Go, but as previously mentioned this rule will be useful |
||||||
|
in any other language as well.) |
||||||
|
|
||||||
|
Let's go through a few examples, to show the various ways that this rule can |
||||||
|
manifest in actual code. |
||||||
|
|
||||||
|
**Example 1: Nothing to add** |
||||||
|
|
||||||
|
In this example we have a function which merely wraps a call to `io.Copy` for |
||||||
|
two files: |
||||||
|
|
||||||
|
```go |
||||||
|
func copyFile(dst, src *os.File) error { |
||||||
|
_, err := io.Copy(dst, src) |
||||||
|
return err |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
In this example there's no need to modify the error from `io.Copy` before |
||||||
|
returning it to the caller. What would we even add? The caller already knows |
||||||
|
which files were involved in the error, and that the error was encountered |
||||||
|
during some kind of copy operation (since that's what the function says it |
||||||
|
does), so there's nothing more to say about it. |
||||||
|
|
||||||
|
**Example 2: Annotating which step an error occurs at** |
||||||
|
|
||||||
|
In this example we will open a file, read its contents, and return them as a |
||||||
|
string: |
||||||
|
|
||||||
|
```go |
||||||
|
func readFile(path string) (string, error) { |
||||||
|
f, err := os.Open(path) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("opening file: %w", err) |
||||||
|
} |
||||||
|
defer f.Close() |
||||||
|
|
||||||
|
contents, err := io.ReadAll(f) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("reading contents: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return string(contents), nil |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
In this example there are two different steps which could result in an error: |
||||||
|
opening the file and reading its contents. If an error is returned then our |
||||||
|
imaginary caller doesn't know which step the error occurred at. Using our rule |
||||||
|
we can infer that it would be good to annotate at _which_ step the error is |
||||||
|
from, so the caller is able to have a fuller picture of what went wrong. |
||||||
|
|
||||||
|
Note that each annotation does _not_ include the file path which was passed into |
||||||
|
the function. The caller already knows this path, so an error being returned |
||||||
|
back which reiterates the path is unnecessary. |
||||||
|
|
||||||
|
**Example 3: Annotating which argument was involved** |
||||||
|
|
||||||
|
In this example we will read two files using our function from example 2, and |
||||||
|
return the concatenation of their contents as a string. |
||||||
|
|
||||||
|
```go |
||||||
|
func concatFiles(pathA, pathB string) (string, error) { |
||||||
|
contentsA, err := readFile(pathA) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("reading contents of %q: %w", pathA, err) |
||||||
|
} |
||||||
|
|
||||||
|
contentsB, err := readFile(pathB) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("reading contents of %q: %w", pathB, err) |
||||||
|
} |
||||||
|
|
||||||
|
return contentsA + contentsB, nil |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Like in example 2 we annotate each error, but instead of annotating the action |
||||||
|
we annotate which file path was involved in each error. This is because if we |
||||||
|
simply annotated with the string `reading contents` like before it wouldn't be |
||||||
|
clear to the caller _which_ file's contents couldn't be read. Therefore we |
||||||
|
include which path the error is relevant to. |
||||||
|
|
||||||
|
**Example 4: Layering** |
||||||
|
|
||||||
|
In this example we will show how using this rule habitually results in easy to |
||||||
|
read errors which contain all relevant information surrounding the error. Our |
||||||
|
example reads one file, the "full" file, using our `readFile` function from |
||||||
|
example 2. It then reads the concatenation of two files, the "split" files, |
||||||
|
using our `concatFiles` function from example 3. It finally determines if the |
||||||
|
two strings are equal: |
||||||
|
|
||||||
|
```go |
||||||
|
func verifySplits(fullFilePath, splitFilePathA, splitFilePathB string) error { |
||||||
|
fullContents, err := readFile(fullFilePath) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("reading contents of full file: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
splitContents, err := concatFiles(splitFilePathA, splitFilePathB) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("reading concatenation of split files: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
if fullContents != splitContents { |
||||||
|
return errors.New("full file's contents do not match the split files' contents") |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
As previously, we don't annotate the file paths for the different possible |
||||||
|
errors, but instead say _which_ files were involved. The caller already knows |
||||||
|
the paths, there's no need to reiterate them if there's another way of referring |
||||||
|
to them. |
||||||
|
|
||||||
|
Let's see what our errors actually look like! We run our new function using the |
||||||
|
following: |
||||||
|
|
||||||
|
```go |
||||||
|
err := verifySplits("full.txt", "splitA.txt", "splitB.txt") |
||||||
|
fmt.Println(err) |
||||||
|
``` |
||||||
|
|
||||||
|
Let's say `full.txt` doesn't exist, we'll get the following error: |
||||||
|
|
||||||
|
``` |
||||||
|
reading contents of full file: opening file: open full.txt: no such file or directory |
||||||
|
``` |
||||||
|
|
||||||
|
The error is simple, and gives you everything you need to understand what went |
||||||
|
wrong: while attempting to read the full file, during the opening of that file, |
||||||
|
our code found that there was no such file. In fact, the error returned by |
||||||
|
`os.Open` contains the name of the file, which goes against our rule, but it's |
||||||
|
the standard library so what can ya do? |
||||||
|
|
||||||
|
Now, let's say that `splitA.txt` doesn't exist, then we'll get this error: |
||||||
|
|
||||||
|
``` |
||||||
|
reading concatenation of split files: reading contents of "splitA.txt": opening file: open splitA.txt: no such file or directory |
||||||
|
``` |
||||||
|
|
||||||
|
Now we did include the file path here, and so the standard library's failure to |
||||||
|
follow our rule is causing us some repitition. But overall, within the parts of |
||||||
|
the error we have control over, the error is concise and gives you everything |
||||||
|
you need to know what happened. |
||||||
|
|
||||||
|
## Exceptions |
||||||
|
|
||||||
|
As with all rules, there are certainly exceptions. The primary one I've found is |
||||||
|
that certain helper functions can benefit from bending this rule a bit. For |
||||||
|
example, if there is a helper function which is called to verify some kind of |
||||||
|
user input in many places, it can be helpful to include that input value within |
||||||
|
the error returned from the helper function: |
||||||
|
|
||||||
|
```go |
||||||
|
func verifyInput(str string) error { |
||||||
|
if err := check(str); err != nil { |
||||||
|
return fmt.Errorf("input %q was bad: %w", str, err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
`str` is known to the caller so, according to our rule, we don't need to include |
||||||
|
it in the error. But if you're going to end up wrapping the error returned from |
||||||
|
`verifyInput` with `str` at every call site anyway it can be convenient to save |
||||||
|
some energy and break the rule. It's a trade-off, convenience in exchange for |
||||||
|
consistency. |
||||||
|
|
||||||
|
Another exception might be made with regards to stack traces. |
||||||
|
|
||||||
|
In the set of examples given above I tended to annotate each error being |
||||||
|
returned with a description of where in the function the error was being |
||||||
|
returned from. If your language automatically includes some kind of stack trace |
||||||
|
with every error, and if you find that you are generally able to reconcile that |
||||||
|
stack trace with actual code, then it may be that annotating each error site is |
||||||
|
unnecessary, except when annotating actual runtime values (e.g. an input |
||||||
|
string). |
||||||
|
|
||||||
|
As in all things with programming, there are no hard rules; everything is up to |
||||||
|
interpretation and the specific use-case being worked on. That said, I hope what |
||||||
|
I've laid out here will prove generally useful to you, in whatever way you might |
||||||
|
try to use it. |
||||||
|
|
Loading…
Reference in new issue