a code generator for generating good error handling pattern for golang 👿
// 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.
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.
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 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.
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 😄️).
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.
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.
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.
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
.
- capture stack frame automatically
- generate tests for template automatically