Super short summary: Exiting your program when you get an error can be a good idea. Using gobail will make your life easier.
When you get an error in your Go code, you'll normally see something like this:
err := myFunc()
if err != nil {
return fmt.Errorf("doing my thing: %w", err)
}
You'll notice a few things in this example:
- You have to check whether there is an error
- There is some text that will help diagnose the error
- And the error is wrapped, passed back with the text
So what happens next? Well it all happens again. You check the error value, describe it, pass it back. And then it all starts again.
Why do we do this? Why all this effort?
Well it all depends on the software that you're writing. When you get an error, you have to make a decision. What happens to that error?
If you're writing an HTTP API responding to a request then eventually you're going to get to some kind of HTTP handler and you'll turn that error into some response - perhaps a little 400 with a polite reminder format their requests properly or possibly a 500 and a worrying message about the health of your application. Alternatively, if you're writing some kind of CLI tool then you might decide that the error will eventually get passed all the way back to your main
function. For any kind of program, you might decide that enough is enough - the program should just end because you can't do anything else.
Let's look at this last option. When is it appropriate to just exit
your program? Here's a few reasons that I can think of:
a. There's nothing else to be done, the error is so bad that everything has to stop right now
b. There are no consequences to terminating the program (no cleanup is required, no status to respond with)
c. Stopping early is desirable, perhaps you have a monitor that restarts the process cleanly
Whatever the reason, you need to consider how you're going to exit cleanly. Now the first thing you're likely to try is this:
err := myFunc()
if err != nil {
fmt.Printf("doing my thing: %v", err)
os.Exit(1)
}
It looks pretty similar to our original error handling code but with a couple of important differences. The first is kind of obvious - there's a honking great stop-right-g*****n-now statement there. Your program is not going to continue. The second point is perhaps more important. The code that is calling this sample doesn't have worry about handling any errors. There are no additional code paths that have to be tested - we can trust that that the calling code doesn't have an if
block to test because there's nothing returned that we have to check.
Testing your exits
So I was being, perhaps, a little enthusiastic when I suggested you should just trust that your exit code is just going to work. You should probably check that the reason that you new program has stopped is for the right reason.
Attempt 1 - just run your program
This feels like it should be easy. Here's some things to consider - in the most easy case, you're just going to run your program and trigger an error condition. For example, get your CLI tool to open a file that doesn't exist. You can do this manually for a few simple cases. When the number of tests goes up, you'll probably need some kind of automation around it to help you.
Quick side note - this is probably the subject of a whole other blog post but my current favourite way of testing CLI tools uses godog to write tests. It can be a little complex but I've found it supremely powerful. Here are some good examples of how I've approached it with layli and wait-for.
This approach will get you very far but sometimes it can be difficult to create the conditions that will properly exercise all of the code paths that you want to be confident about.
Attempt 2 - mock out the exit
OK so now we're going to be using some of the features of the Go language. We don't actually have to call os.Exit
- we can call something that looks like it. So look at this:
type ExitFunc func(code int)
var customExit ExitFunc = os.Exit
func myFunc() {
err := someOtherFunc()
if err != nil {
fmt.Printf("doing my thing: %v", err)
customExit(1)
}
}
So how are we going to take advantage of this for our testing? As the function has now been turned into a variable (customExit
) then we can replace the value with something else that we want to do. Like so...
package sameAsTheAboveCode
func TestOMGItsAllGoneWrong(t *testing.T) {
oldExit := customExit
defer func() {
// Make sure we reset the exit so it can be used elsewhere
customExit = oldExit
}
exitCalled := false
exitCode := 0
customExit = func(code int) {
exitCalled = true
exitCode = code
}
// Set up the mocks so that someOtherFunc returns an error
myFunc()
// Assume that we're using the fantastic stretchr/testify library here
assert.True(t, exitCalled)
assert.Equal(t, 1, exitCode)
}
This is a much more unit-test friendly approach. You can check that the exit code used is correct - and that you actually called the exit function.
On the surface, this looks great but there is one big problem - if your test passes, then you program will continue and execute the rest of the function when you expected it to exit. It will continue even though the test setup means that the rest of the execution is not valid and cause problems for your tests, such as causing panics.
Removing code to remove tests
Well, this sounds a bit extreme!
I feel like I should explain... Typically in "well managed" companies you need to make sure that every line of code has been proved to be working before if can be put in front of your customers. Using the above techniques you may not be able to generate the correct coverage metrics to attest that you're good. Even if it's trivial to reason about.
All of the examples above have assumed that when we get an error then we have to check it to decide what to do (Exit with vengeance). Wouldn't it be great if we were able to exit without having to check that there was an error?
Let's see what we can do.
func checkExit(err error) {
fmt.Printf("doing my thing: %v", err)
customExit(1)
}
func myFunc() {
checkExit(someOtherFunc())
}
Take a look at the above example. The functionality is the same but the implementation of myFunc
is much simpler now - there are no conditionals. We can check the implementation of the checkExit
function in it's own tests, meaning that whatever is new in myFunc() can be much more easily verified.
Introducing gobail
A new libraray, gobail has been created that allows you to e confident that if there is an error then it will be handled without having to add complexity to your own code. It looks like this:
func myFunc() {
gobail.Run(someOtherFunc()).OrExit("with message")
gobail.Run(yetAnotherFunc()).OrExit("with message")
}
This library has been fully tested, with coverage metrics, to prove it. You can safely use it without having to worry that an error will be skipped. It will also handle functions with 2 return values, like so:
gobail.Return2(return2ValsAndError()).OrExitMsg("something went wrong: %v")
Notice as well that you an include the error that is causing all of your problems.
It's also possible to panic instead of exiting, printing our a stack trace and other contextual information from the program when the panic is invoked. Take a look at the docs for more details.
Isolating dependencies
As you write software with gobail, you will notice that you will mostly have to use it when talking to external libraries. This has the additional code that you typically need to write to handle all of the error cases can be wrapped in a call to Return
or Return2
and assume that we will exit when necessary.
Conclusion
Sometimes it's desirable to exit your program instead of handling your errors in detail. The gobail library has been created and validated so that you don't have to worry about the detail of proving this.
If you find an improvement that could be made or just have a suggestion then raise a PR or an issue on the repo and the developers will get on it when they can!
Top comments (0)