Hacker News new | ask | show | jobs
by jmarchello 1036 days ago
The lack of Error Handling in Go is a feature, not a bug. See here: https://go.dev/doc/faq#exceptions. I think I'd be disappointed if Try/Catch ever made their way into the language.
5 comments

Error handling != exceptions.

Step one would bee sum types, so only valid value space can be represented (return value or error, but not both or neither).

Good point, a poor assumption on my part
What would be the big gain from this, over the existing approach using multiple return values?
Great, so Go has support for native stacktraces so bubbled errors don't get shadowed?

Just because Go made opinionated design decisions around their error handling a decade ago when developing the language doesn't mean that there's not practical room for improvement as the language is widely in production and shortcomings in its error handling have been found.

The number of hacks I've seen over the years to try and solve the "wait, where did this error originate" problem in Go are legion, with little standardization.

And no, using Errorf with '%w' to wrap error messages along the stack isn't exactly an elegant solution.

Even if they want to keep the core error behavior as it is for compatibility, providing a core library way of wrapping with stacktraces would be a very useful next step, particularly given the most popular package doing that previously is now unmaintained.

> providing a core library way of wrapping with stacktraces would be a very useful next step

What eventually became the standard library error wrapping proposal evolved from the work done on the Upspin project. It did include stacktraces, and believed like you that it would be useful to have them. But analysis of the data showed that nobody ever really used them in practice and, for that reason, was removed from the final proposal.

> particularly given the most popular package doing that previously is now unmaintained.

Lacking wide appeal doesn't mean there isn't a niche need, of course. However, with the standard library accepting a standard for error wrapping, which this package you speak of has been updated to be compatible with, what further maintenance would be needed, exactly? It would be more concerning if it wasn't considered finished by now. It seems the solution for niche needs is right there.

In the last few months I've realized what I desperately need: a way to wrap an error with a call stack at the point where it enters our code base. This would probably save me on average 20-30 minutes a week.

I see this all the time:

   main.go:141 error: could not transmogrify the thing: a144cd21c48
And then I literally grep the code base to find the error message. That works ~50% of the time, but the other 50%, I see this:

   main.go:141 error: not found
And then I have to spend 5-10 minutes spelunking to try to find where that error might have originated from.

But this would be amazing:

   main.go:141 error: not found callstack=...
This is such an infuriating problem. I'm convinced I'm using Go wrong, because I simply can't understand how this doesn't make it a toy language. Why the $expletive am I wasting 20-30 and more minutes per week of my life looking for the source of an error!?

Have you seen https://github.com/tomarrell/wrapcheck? It's a linter than does a fairly good job of warning when an error originates from an external package but hasn't been wrapped in your codebase to make it unique or stacktraced. It comes with https://github.com/golangci/golangci-lint and can even be made part of your in-editor LSP diagnostics.

But still, it's not perfect. And so I remain convinced that I'm misunderstanding something fundamental about the language because not being able to consistently find the source of an error is such an egregious failing for a programming language.

I find it interesting how, as soon as the word error shows up, people seemingly forget how to program.

Ignore the word error for a moment. Think about how you program in the general case, for a hypothetical type T. What is it that you do to to your T values to ensure that you don't have the same problem?

Now do that same thing when T is of the type error. There is nothing special about errors.

Its flaws or merits aside, when you have no other useful context to add to the error, that's precisely what Errorf is for.

  func bar() error {
    err := baz.Transmogrify()
    return fmt.Errorf("transmogrify: %w", err)
  }

  func foo() error {
    err := bar()
    return fmt.Errorf("bar: %w", err)
  }

  func main() {
    err := foo()
    fmt.Printf("foo: %v", err)
    // foo: bar: transmogrify: not found
  }
There's your callstack, without the cost of carrying around the actual callstack.
Nitpicking here, but I prefer a different convention.

  func bar() error {
    err := baz.Transmogrify()
    return fmt.Errorf("bar: %w", err)
  }

  func foo() error {
    err := bar()
    return fmt.Errorf("foo: %w", err)
  }

  func main() {
    err := foo()
    fmt.Printf(err)
    // foo: bar: transmogrify: not found
  }
Also, I tend to skip quite a lot of layers. The (only?) advantage of manual wrapping over stack traces is that a human can leave just 3 wrappings which are deemed sufficient for another human, while stack trace would contain 100 lines of crap.

    func bar() error {
        err := baz.Transmogrify()
        return fmt.Errorf("bar: %w", err)
    }
This is broken. If baz.Transmogrify() returns a nil error, bar will return a non-nil error.

Also, annotations like this, which repeat the name of the function, are backwards. The caller knows the function they called, they can include that information if they choose. Annotations should only include information which callers don't have access to, in this case that would be "transmogrify".

The correct version of this code would be something like the following.

    func main() {
        fmt.Printf("err=%v\n", foo())
    }
    
    func foo() error {
        if err := bar(); err != nil {
            return fmt.Errorf("bar: %w", err)
        }
        return nil
    }
    
    func bar() error {
        if err := baz.Transmogrify(); err != nil {
            return fmt.Errorf("transmogrify: %w", err)
        }
        return nil
    }
Indeed, our code base is littered with fmt.Errorf("...: %w", err), but that only works if enough places in the code add context. Currently only about 15% of return sites do this.

And I disagree that the cost of carrying around the callstack is something to worry about. Errors are akin to exceptions in C++/Java: no happy path should rely on errors for control flow (except io.EOF, but that won't generate a call stack). They should be rare enough that any cost below about 1ms and 10k is negligible.

Every error should be annotated at the call site. fmt.Errorf("...: %w", err) isn't litter, it should be a basic expectation of any code which passes code review.

> Errors are akin to exceptions in C++/Java: no happy path should rely on errors for control flow (except io.EOF, but that won't generate a call stack). They should be rare enough that any cost below about 1ms and 10k is negligible.

This may be true in C++ or Java, but in Go, it is absolutely not the case.

Errors are essential to, and actually the primary driver of, control flow!

Any method or function which is not guaranteed to succeed by the language specification should, generally, return an error. Code which calls such a method or function must always receive and evaluate the returned error.

Happy paths always involve the evaluation and processing of errors received from called methods/functions! Errors are normal, not exceptional.

(Understanding errors as normal rather than exceptional is one of the major things that distinguish junior vs. senior engineers.)

> that only works if enough places in the code add context.

It would be a bit odd to not add context, wouldn't it? Same goes for any value. This is not exclusive to errors. If you consider a function which returns T, the T value could equally be hard to trace back if you find you need to determine its call site and someone blindly returned it up the stack. There is nothing special about errors.

While ideally you are returning more context than Errorf allows, indeed, it is a good last resort. If your codebase is littered with blind returns, the good news is that it shouldn't be too hard to create a static analyzer which finds blind returns of the error type and injects the Errorf pattern.

Are you suggesting it's OK if ParseInt failures take 1ms? Or should ParseInt use a different "kind of error" that's not commensurate with the regular error kind?

Do you think most errors look more like ParseInt, or more like sql.Open where 1ms might be acceptable? (Do you think a call stack from the insides of sql.Open would be useful? My experience, mostly not...)

So the stacks should probably only be for "complex errors", and only for frames that happen in code you (hand waving) "care about". Maybe your programs just have far too complex internal error handling?

Not exactly -- you should only fmt.Errorf wrap errors which are non-nil.

See my sibling comment: https://news.ycombinator.com/item?id=37234455

Agreed. In fact I wrote a (very) small library to help deal with it.

https://github.com/kitd/chock

>And no, using Errorf with '%w' to wrap error messages along the stack isn't exactly an elegant solution.

I don't think anyone has ever claimed otherwise. But I do think its a pretty good solution. Whats elegance worth, anyways?

Yeah I have to agree that the Go-style error handling does actually lead to better code. At least when I write it. It makes me think through how I am going to handle error states rather than chucking it in try/except in Python and hoping nothing breaks lol.
Rust-style error handling works better though - similar to Go, but with the addition of enums such that the precise types of errors which may be encountered can be easily documented in the type system.
Agreed, I’d love if they would take inspiration from Rust and bring that to Go.
Yes - if Go had sum types (and they were idiomatically used in the standard library), it would take it from a pretty good platform to a first class one.

The library ecosystem is already excellent, and the tooling is good, lack of sum types is the single wart that makes me regret it every time I pick Go up for a project.

Try/catch/finally is in the language, its just called panic/defer/recover, where panic works like throw, every function works like try, defer works like a combination of a nonselective catch that rethrows by default and finally, and recover disables the rethrows-by-default behavior, while also being the only way to interrogate the panic to see if you should do that.
I am not demanding either of these things. Just making general comment based on observing folks.