README
¶
thaterror
a code generator for generating good error handling pattern for golang 👿
Demo
// TraceError is the error in tracing a process
// +thaterror:transparent
// +thaterror:wrap=*PtraceError
// +thaterror:wrap=*WaitPidError
// +thaterror:wrap="pkg/commonerror".*IOError
// +thaterror:wrap="pkg/commonerror".*ParseIntError
// +thaterror:wrap="pkg/mapreader".*Error
type TraceError struct {
Err error
}
// PtraceError represents an error returned by ptrace syscall
// +thaterror:error=fail to ptrace({{.Operation}}) on process {{.Tid}}
type PtraceError struct {
Err error
Tid int
Operation int
}
// WaitPidError means the waitpid syscall returns an error
// +thaterror:error=waitpid failed
type WaitPidError struct{}
func Trace(pid int) (*TracedProgram, *TraceError) {
return nil, TraceErrorWrap(&commonerror.IOError{
Err: err,
})
}
As shown in the demo, we can add a lot of +thaterror:wrap annotation for the
struct TraceError, and the code generator will generate TraceErrorWrap
function. Only the listed types (in the annotation) can be passed into the
TraceErrorWrap function. Passing other types of variables into the
TraceErrorWrap function will result in a compiling error.
+thaterror:wrap is the most important annotation. The others like
+thaterror:error and +thaterror:transparent are just helpers to implement
error interface. With +thaterror:error, the error message is generated with
the specified template, the . of which is the struct it self.
Motivation
Error handling in Go is always a problem. There are tons of articles and
libraries about it. In 2020, this problem is still far from being solved, though
the
xerrors
(based on Go 2 proposal) seems to be a little step towards the answer. However,
from the very begining, our demands for error handling is quite simple: we just
need to know all possible errors the function will return, and handle them
differently.
To prove this statement, I will show you an example. For the function New in
the package "github.com/hashicorp/golang-lru", the function signature is:
func New(size int) (*Cache, error)
It gives an error. But how could creating an lru result in an error (without
considering the memory allocation failed error)? After exploring the source
code of this function, you will know the only error is that: "Must provide a positive size". As the user of this function, I'm confident enough to ignore
the error now 😃 (though printing it out could be a better idea), as I will call
it with a constant positive integer.
However, exploring the source code may take a lot of time. If the author of the
packages writes down all the possible errors in the document of the function,
this problem will be solved, but little programmers will do it well (which has
been proved in the Go ecosystem today 👿).
Let's take a look on how other programing languages solve the problem.
Java
The type of exceptions can be declared at the end of a function signature.
import java.io.*;
public class className {
public void methodName() throws ExceptionOne, ExceptionTwo {
possibleExceptionTwo();
throw new ExceptionOne();
}
}
So that the function signature will tell you all possible (checked) exceptions.
Rust
Rust has a powerful
enum, and it
can be used as a generic parameter:
enum Error {
ErrorOne,
ErrorTwo,
ErrorThree(String),
ErrorFour(u8, u8, u8, u8),
}
type Result<T> = std::result::Result<T, Error>
fn some() -> Result<()>
The definition of Result in std has nothing mystery:
enum Result<T, E> {
Ok(T),
Err(E),
}
The programmers can use match, if let, etc, to assert whether it's an Err
or Ok. A ? operator has been added to simplify the error assertion and type
convertion
(document).
The approach is very similar with Java. The only difference is that the
"union" of error type has to be declared in Rust. The anonymous sum type
RFC has been posted to solve
this problem.
C
The most significant "C" way is by defining error number or error return value.
The errno is a global thread local variable, through which you can get the
number of last error. Documents of the system call (or library function) should
describe the value.
For example, the signature of open function:
int open(const char *pathname, int flags);
The manual page will describe all possible errors and how it occurs:
open(),openat(), andcreat()can fail with the following errors:EACCES The requested access to the file is not allowed, or search permission is denied for one of the directories in the path prefix of pathname, or the file did not exist yet and write access to the parent directory is not allowed. (See also path_resolution(7).)
EACCES Where O_CREAT is specified, the protected_fifos or protected_regular sysctl is enabled, the file already exists and is a FIFO or regular file, the owner of the file is neither the current user nor the owner of the containing directory, and the containing directory is both world- or group-writable and sticky. For details, see the descriptions of /proc/sys/fs/protected_fifos and /proc/sys/fs/protected_regular in proc(5).
EBUSY O_EXCL was specified in flags and pathname refers to a block device that is in use by the system (e.g., it is mounted).
...
There are hundreds of error numbers, which can be regarded as a "super union" of errors (and you can call it error universe 😄️).
C++
There is no recommended way or standard way to handle error in C++. You can
choose to use "throw and catch" exception without throw in the signature,
which means the users will also fail to know all the possible error in a
function. Or you can choose to use std::expected (not in standard yet) to get
nearly the same error handling pattern like the Rust way. If you are building
a c binding or love the old fashion way, error code is also a choice for C++
programmers.
Conclusion
After these examples, if we don't care about the control flow (the difference between throw and return), the sum type is nearly the only way to know the errors in a function. We need to simulate sum type in go.
Implementation
The implementation for +thaterror:error and +thaterror:transparent is quite
straightforward. It creates a template for every error and the implementation of
Error() string functions is rendering the template. For more documents about
the template you can find in
text/template.
Though there is no tagged union (or sum type) in go, we can use interface to
simulate it. This method has been used in some libraries, e.g.
ast
// All statement nodes implement the Stmt interface.
type Stmt interface {
Node
stmtNode()
}
func (*BadStmt) stmtNode() {}
func (*DeclStmt) stmtNode() {}
func (*EmptyStmt) stmtNode() {}
func (*LabeledStmt) stmtNode() {}
func (*ExprStmt) stmtNode() {}
If you specify the Stmt as the type of a variable, the all possible types of
it are those listed below. There is also a tool
typeswitch to check whether
all types has appeared in the switch branch (but I haven't tried it).
In thaterror, we use the same way to implement +thaterror:wrap. We will
create an interface for the type which could Wrap other types, then we
implement this interface for all these types.
// +thaterror:wrap=*MissingTemplateName
// +thaterror:wrap="pkg/commonerror".*IOError
type Error struct {
Err error
}
thaterror will generate an interface for it to conclude all possible wrapped
errors:
type ErrorWrapUnion interface {
PkgwebhookconfigError()
error
}
And related type, e.g. "*IOError" will implement this function:
func (err *IOError) PkgwebhookconfigError() {}
However, this information will lose after Unwrap. We have to set the return
type of Unwrap to error but not ErrorWrapUnion, because the function in Go
is not covariant with the return value.
Install & Use
thaterror hasn't prepared to be widely used. The documents and tests are not
rich enough. You can install and read the help information to have a try. If you
have any suggestion on the error handling tools or lints, feel free to open an
issue and help us to improve thaterror.
TODO List
- capture stack frame automatically
- generate tests for template automatically