So you're bubbling up the error without annotating it... great
First, if you want to add extra annotations or scope to the error, you can actually do so—and trivially—while still using that single `?`. Widely-used error crates like `thiserror` allow you to specify that (for example) an I/O error will be automatically wrapped with `?` by some custom error type specific to your crate that conveys more information about what went wrong. This is phenomenal for errors that need to be bubbled up to end-users.
Second, for the majority of errors that are normal, expected, and recoverable, annotating them is just pointless busywork since they'll never be visible from outside of your program. For example, errors that eventually bubble up to an `.ok_or(...)` receive zero benefit from being annotated.
Third, is your preferred alternative the Go approach where you function as a less-capable human exception handler? Having to hunt through the source to identify what actually happened through some contortionist `error: thing went wrong: subsystem died: api client failed: gcloud client: cache error: filesystem error: file not found: tmp.VRVcBX1j` with no line numbers or function names, and various random components of the error string coming from either third-party libraries or the golang standard library? This is just so comically terrible to anyone who's spent time in languages with decent error handling it's genuinely hard to believe that people regularly come to its defense.
But of course I'm being generous here when we both know the actual status quo in the overwhelming majority of production Go projects is to simply bubble up the error with `return nil, err` with no context whatsoever, so you just get `error: file not found: tmp.VRVcBX1j` with absolutely no idea of where it came from. Those are always my favorite.
So, to recap: with Rust's `?` operator you actually can have your cake and eat it too. You can add library-specific context to your errors while actually wrapping the underlying error and not merely mashing strings together. You can opt into stack traces for your own code if you want to. And you can skip the annotations for code where you handle errors and don't bubble them up. The only apparent downside is that it's not overly verbose enough for Go adherents.
In Rust, errors are generally either a struct (to represent a single possible kind of error) or an enum of structs (to represent multiple potential underlying errors). These aren't C-style enums, they're sum types. So if your function returns a Result with an Error in it, that error is precisely one of those underlying struct types.
This has some enormous advantages. If the library author provided a way to convert their errors to a human-readable string, you can simply call that method and do so (similar to go's error interface). If they didn't or you would prefer to use your own string descriptions, the enumeration provides the complete list of possible error types to the compiler. So you can match (a.k.a. case or switch) on the error type and convert them to strings of your own choosing and guarantee statically at compile time that every possible error type is handled. You can use this same machinery to detect the error type and recover from ones you know how to handle or bubble up the ones you can't.
This is much more powerful and flexible than the Go equivalent and doesn't exactly come with much additional mental burden. With Go, the only thing you're promised is that your error type can be turned into a string, that's it. You can check that the errors are of a certain type, but there is no way to know at compile-time what all possible errors are. In fact, because most Go programs just use strings as errors directly (`fmt.Errorf("bad thing happened")`), the only types of errors you can generally detect and recover safely from are ones that happen in functions that can fail in precisely one way or functions that have only one possible way to recover from all their failure modes.
Of course, Go programs could implement error structs that you can switch on. But nobody in practice seems to do this. And even if they did, there are no compile-time guarantees that ensure you're covering every unique failure case. If the function you're calling adds a new error type, there's no way to know this other than to have an `else` that covers "everything I didn't know about".
Let's take one toy example: creating a file. This could fail because the directory doesn't exist. This could fail because we don't have permissions to write to the directory. In both of these cases maybe we want to fall back to an alternate location. Or it could fail because of something unrecoverable: your disk is full. In Rust, this is trivial to do. You can match on the error type, handle ErrorKind::NotADirectory, ErrorKind::NotFound, etc., and fallback. For anything else, bubble the error up. In Go, you get an error back. The docs promise that it's of type *PathError, but that isn't enforced by the compiler so you get to typeswitch. Even then that doesn't really help you much because fs.PathError is just
type PathError struct {
Op string
Path string
Err error
}
All you get about that internal error is that it's convertible to a string. So after typeswitching, now you could theoretically switch on `error.Err.String()` to do this but now you need to figure out every possible string that is returned for the error cases you want to handle. And of course, those strings could change in future updates or new ones could be added without you ever knowing.