go error handling lolz

This commit is contained in:
Wouter Groeneveld 2024-03-06 20:48:35 +01:00
parent fcc91ce3de
commit 93cfce34a2
1 changed files with 100 additions and 0 deletions

View File

@ -0,0 +1,100 @@
---
title: Error Handling No-Goes In Go
date: 2024-03-06T20:08:00+01:00
tags:
- go
categories:
- programming
---
I came across a weird function in our Go codebase the other day. It was supposed to do just one thing but accidentally did a bit more---you know how this goes. Suppose you're validating some business rule and need to fetch a bit of data to do so:
```go
func (s *Service) IsPeriodInvoicable(ref EntityRef) (bool, string, string, error) {
beginP, err := s.FetchBeginPeriod(ref)
if err != nil {
return false, "", "", fmt.Errorf("Can't fetch begin period: %w", err)
}
endP, err := s.FetchEndPeriod(ref)
if err != nil {
zerolog.Warn("Wedontcare but let's log just in case!", err)
return true, beginP, "", nil
}
err := s.DoMoreBusinessStuff(ref, beginP, endP)
if err != nil {
return fasle, beginP, endP, fmt.Errorf("Something else is wrong: %w", err)
}
return true, beginP, endP, nil
}
```
What is that all about? In some cases, we return the thing that went wrong. In other cases, we don't and eat up the error. Thanks to Go's "idiomatic" tuple return format for `func`s, where `(result, error)` is commonplace, it's easy to sneak in a few extra objects that shouldn't get leaked out: the function name is a simple yes/no question.
And then there's the verbose and "idiomatic" (lot's of air quoting today) Go way to approach error handling that makes every single `func` with more than 4 function calls that each return a possible error pretty much unreadable. I bet for the whole codebase, in lines of code, that's 30% of boilerplate junk.
Another reason this code sucks isn't just the order dependence, but the way default options of return arguments have to be filled in. If you return a struct value, you're left with `return MyStruct{}, err` in case of an error since `nil` only works with pointers, while in reality you _don't_ want to return anything except for the error itself.
Here's how Java/.NET does things:
```java
public boolean isPeriodInvoicable(ref EntityRef) {
try {
var beginP = this.FetchBeginPeriod(ref);
var endP = this.FetchEndPeriod(ref);
s.DoMoreBusinessStuff(ref, beginP, endP);
return true;
} catch(InvalidPeriodException ex) {
logger.error("Invalid period", ex);
return false;
}
}
```
I don't know why but I encounter a lot of resistance from Go folks to write DDD-style code. The above Java code is Go-style Java code, and far from what I'd write. Instead, why don't we take in an `Invoice` object and ask for the period on the object itself? Anyway. You can take this further by either extracting the inner contents of the `try {}` in a separate method, or by adding `throws InvalidPeriodException` and perhaps making it a checked one.
Yes, in Java you can `catch(Exception ex) { }`. But guess what, I also encountered Go code where the `error` variable was shadowed, the function returned, and the last call that potentially returns an error forgotten or ignored. That's exactly the same: your language and your idioms won't stop you from making programming mistakes. But it will force you to deal with plumbing code.
And then I read [how Rust handles errors](https://doc.rust-lang.org/book/ch09-00-error-handling.html). What an elegant way of dealing with them! Rust also doesn't feature exceptions and also has either panics[^p] that stop execution or errors that can be recovered from. Rust doesn't make you return a tuple to deal with normal values _and_ exceptions: in Rust, you instead return a `Result<T, E>` enum that either contains a result `T` or an error `E`:
[^p]: Yes, I know, you can `defer` and `recover()`. Great. Don't do that (even though in the standard library this has been done on several occasions)!
```rust
fn isPeriodInvoicable(ref: EntityRef) -> Result<bool, pkg::InvalidPeriodError> {
let beginP = fetchBeginPeriod(ref)?;
let endP = fetchEndPeriod(ref)?;
doMoreBusinessStuff(ref, beginP, endP)?;
Ok(true);
}
```
How's that for readability? It uses the special `?` operator as a shorthand for pattern matching. To understand what Rust does behind the scene, we'll rewrite that in a more verbose manner:
```rust
fn isPeriodInvoicable(ref: EntityRef) -> Result<bool, pkg::InvalidPeriodError> {
let beginP = match fetchBeginPeriod(ref) {
Ok(p) => p,
Err(e) => return Err(e),
};
let endP = match fetchEndPeriod(ref) {
Ok(p) => p,
Err(e) => return Err(e),
}
// ...
}
```
Using `?`, Rust automatically wraps/converts the error into the bubble-up one in the generic `Result` parameter: the `InvalidPeriodError` type. I know little about Rust and it seems that [generic error conversion](https://stackoverflow.com/questions/48430836/rust-proper-error-handling-auto-convert-from-one-error-type-to-another-with-que) can get complicated and the language is still evolving (see [structuring and handling Rust errors in 2020](https://nick.groenen.me/posts/rust-error-handling/)), but I do know that by using the error pattern matching (shorthand), your code is readable, and by writing endless `if err != nil { }` Go lines, your code is not.
Need more convincing? Read Jesse Duffield's [Go'ing Insane Part One: Endless Error Handling](https://jesseduffield.com/Gos-Shortcomings-1/). Go's error handling system has been controversial for years---in fact, for Go 2.0, a lot of feedback and proposals [have been gathered](https://go.dev/wiki/Go2ErrorHandlingFeedback), such as introducing `try(f())`, except---ha, `except`ion! no, wait, that's not funny---that that's [not the try you're looking for](https://go.googlesource.com/proposal/+/master/design/32437-try-builtin.md) as a Java dev.
If you have to read a lot of enterprise-level Go code, you're bound to scroll over mundane and error-prone error checks. Despite the custom error objects that yes, can be `switch{}`ed upon, but that will only take you so far. Kyle Krull says: [accept it and move on](https://8thlight.com/insights/exploring-error-handling-patterns-in-go). Great advice, except---dang it---I'm starting to resent it.
What I'm trying to say is this: I added Rust on top of my languages-to-learn list.