Different Ways of Reporting Errors

Errors are a key component of writing software - programs, libraries, scripts, you name it. We need to check for them, catch them, mitigate, log, and finally create. In this article, I want to give you an overview of various methods of that last activity - creating errors - sometimes also called raising or throwing, especially when the errors are called exceptions. On that note...

Yeah, before we dive into the topic, let's make it clear: I'll use "error" and "exception" here almost interchangeably. This is because I'm talking here about the abstract case rather than the thing that is used to represent it. If you are coming from e.g., Java you may find this confusing, because these two names are used differently. On the other hand if you are coming from e.g., Python you might be wondering why am I even writing this paragraph.

Now then, as I was writing this article the classification of these methods evolved quite a few times. I doubt this is the final form and depending on the feedback and my future endeavours I hope that I'll continue to make this list better.

red flags

Returning the Error

Let's start with something dead simple. In this method we indicate an occurrence of the error simply by returning a value from the function or the program. There are different ways of doing that mostly in terms of types and error details.

Returning Boolean

I don't think it gets any easier than that. As long as you don't need to return any meaningful value from the function and you only want to indicate whether the function passed or failed. In such case this will do. Just make the function return a boolean that answers "Did the function pass?" or "Did the function fail?"

This method is sometimes used together with some bigger state. Especially if the executed function is a member of some class. An example of such approach can be found in Qt.

Returning Error Codes

If you still don't need to return any meaningful value, but you want to differentiate between errors, you can encode them with non-zero numbers. This time the value answers the question "What error occurred?" where zero means none.

The most common example of this approach is classic shell, where the result of the last command is stored inside the $? variable:

$ ls real_file
real_file
$ echo $?
0
$ ls does_not_exist
ls: cannot access 'does_not_exist': No such file or directory
$ echo $?
2

Interestingly, shells implement if statements where 0 is interpreted as positive case:

$ if ls real_file; then
>    echo "True branch with $?"
> else
>    echo "False branch with $?"
> fi
real_file
True branch with 0

Extreme example of throwing raw error codes at end-users is Windows and its API. I'd encourage you to avoid going to such lengths.

Returning Error Objects

But you don't need to use numbers necessarily. The only requirement is that you remember about the ability to represent all possible cases, including a situation in which no error occurred.

The Go programming language is cleverly using its core mechanics to deal with errors: tuples, nil values and interfaces. Function that wants to raise an error should return object that fulfills a special error interface that requires an Error() method to be present. In case the function does not want to report anything, it can just return nil instead. In simplified code it looks like this:

type TooLargeError int64

func (err TooLargeError) Error() string {
    return fmt.Sprintf("For reason number is too large: %d", err)
}

func CheckNumber(value int64) error {
    if value > 10 {
        return TooLargeError(value)
    }
    return nil
}

func main() {
    err := CheckNumber(4)
    if err != nil {
        fmt.Println(err)
    }
    err = CheckNumber(14)
    if err != nil {
        fmt.Println(err)
    }
}

It is worth noting here the difference between shell and go errors in terms of boolean logic. Depending on your style, e.g., prevalence of early returns, you may want to consider whether to assign positive or negative boolean value to case in which error did not occur. Both are viable.

Returning an Invalid Value

What happens if you want to return a meaningful value from the same function?

In case of shell the return value is rarely used to store actual result, because that's the usual role of the standard output stream. And in the above example of Go, the language has a very good built-in support for handling tuples, so a function can just return a nilable error and the desired thing.

The approach of Go can be used in many other languages, with or without syntactic support, but what if you are forced to return a single primitive object from the function?

Well, you can reverse the Error Codes approach by dedicating one or more from possible values to indicate errors with them. Sometimes selecting those values can be straight-forward - for instance when the domain already has an invalid space. Consider sizes which are usually represented with zero and positive integers, meaning if you use signed integer as return value then you will have all of the negative numbers available to represent errors.

ruler with negative length

This is the approach used by read(3). When successful the function returns amount of bytes read, but on error it returns -1 and sets a special global errno(3) to a value that describes what exact error occurred:

char buffer[1024];
ssize_t bytes = read(fd, buffer, 1024);
if (bytes < 0)
    perror("read()");  // Reads errno and prints description of error
else
    do_something(buffer, bytes);

Note that I previously wrote that you can dedicate one or more values. Although I never found confirmation in the POSIX standard, the only likely reason of read not using more negative numbers to indicate errors is to have consistent interface to retrieve error details. Not all of the functions in the standard have enough available values to indicate all the needed errors.

Anyway, sometimes you have enough values to use but you choose not to use them, and sometimes you may be forced to use a single value. Sometimes you may even need to create your own constraints and rules in order to indicate an error. An example of that is memory allocation with malloc(3) that returns NULL in case of errors:

void* buffer = malloc(4096);
if (NULL == buffer)
    perror("malloc()");  // In case of malloc it's always ENOMEM, really
free(buffer);

C and C++ standards (for NULL and nullptr) try very hard to define those two as null pointer constants forcing compiler and platform implementations into guaranteeing that these will never point to any real object and hopefully cause some segmentation faults here and there.

Returning Wrapped Values

Instead of bundling error with the value in tuple or some other container like Go did, you can wrap the value with an object that will optionally indicate the error. This method may vary from simplified wrapper to a full-pledged monad. Depending on where you end up on this spectrum the main difference will be the flow of error handling. You can use tailored wrappers or something more generic like Either from Haskell or std::variant from C++.

A naive interface of tailored wrapper could look like this:

template<typename T, typename E=const char*>
struct Result {
    Result(T value);
    Result(T value, E message);
    T m_value;
    E m_message;
    bool is_ok() const;
};

And used similarly to this:

Result<int> add_two(int value) {
    if (value > 10)
        return Result<int>(value, "i can't, it's too large");
    return value + 2;
}

int main() {
    for (int i = 8; i < 12; ++i) {
        const auto number = add_two(i);
        std::cout << i;
        if (!number.is_ok())
            std::cout << number.m_message;
        else
            std::cout << number.m_value;
        std::cout << std::endl;
    }
}

There is a very similar case to this one, but instead of value being wrapped, it contains a flag that indicates its validity. This second approach is sometimes called zombie object. An example use of this approach would be streams from C++ STL.

Implementations that are more on the monad-like side may allow user to bind functions to wrappers depending on their state. This is very notably used in JavaScript's promises:

fetch("https://ignore.pl/example.json")
    .then(response => response.json())
    .then(console.log)
    .catch(error => console.log("Error!", error);

Terminating the Process

In a scope of a single function we can use a technique called early return to finish the faulty execution. For example, you could:

struct Message*
new_message() {
    struct Message* msg = malloc(sizeof(struct Message));
    if (NULL == msg)
        return NULL;
    const int res = initialize_message(msg);
    if (-1 == res) {
        free(msg);
        return NULL;
    }
    return msg;
}
killing will commence

Without going deep into a discussion about whether early returns are good or bad (and I recall a few heated discussions about it), you can already see that there is one already mentioned major flaw in it - it operates just on a single level: in functions. Now, one way to overcome this limitation is going full nuclear.

When encountering a critical problem and operating in Unix-like environment you can simply terminate the process. In order to show a distinct death condition you can use standard error stream or return code.

To do that you can use exit(3) in C, sys.exit in Python, exit or die in PHP, and other equivalent functions in other languages. Some of them allow you to provide something to print out or return code, and some don't. In C, you can often see:

noreturn void
panic(const char* fmt, ...) {
    va_list args;
    va_start(args, fmt);
    vdprintf(2, fmt, args);
    va_end(args);
    exit(1);
}

This will format and print provided message to error stream and then terminate process returning 1. Like I mentioned earlier this is pretty much returning an error as a value and doing that earlier than a normal execution. Thanks to the secondary output "lane" - the standard error stream we can provide the details of the error. This could be compared to tuple solution from earlier to some extent.

Due to the fact that this method terminates the entire process it does not fit very well within bigger pieces of software that rely a lot on their own interfaces and control flow. It shines when dealing with critical errors or when working with a set of smaller programs that are running in shell environment.

Throwing Exceptions

When you want your program to be long-living and be able to recover from various failures terminating everything is simply unacceptable and a different solution is needed. To be on the strict side of controlling the flow you may choose to simply chain returning the error from the functions in the stack one by one. This is the path that e.g., Go chose. The other way is a little more loose. It uses a secondary output lane to return the error and traverses the call-stack until the error is handled. In a case that the error was not expected to be handled by developer it may fallback to terminating process. The process of traversing the call-stack is usually called stack unwinding.

This method involves pushing errors into the second output lane - usually called throwing or raising, and a way of limiting the unwinding and reading the pushed error - usually implemented by code blocks or statements that are marked with a try keyword together with either catch or except.

When you need to raise errors of different severities and want to terminate some selected part of your execution consider using exceptions.

Exceptions and exception-like interfaces are implemented in a wide selection of programming languages, for example in Python:

def get(url, max_attempts=4):
    attempts = 1
    while attempts < max_attempts:
        try:
            return requests.get(url)
        except HTTPError as err:
            if err.response.status_code == 404:
                raise
            last = err
            attempts += 1
    raise RetryError from last

Or C++:

int
check_one(int x) {
    if (x < 3)
        throw "too little";
    return x;
}

int
maybe_find(std::vector<int> numbers) {
    int attempts_left = 3;
    for (int i : numbers) {
        try {
            return check_one(i);
        }
        catch (const char* err) {
            if (attempts_left > 0) {
                attempts_left--;
                continue;
            }
            break;
        }
    }
    throw "not found";
}

There are a lot of flavours to the exceptions, but they generally tend towards the description I provided above. They also usually use similar syntax with only small adjustments. Some of them, like Python, limit the objects that can be raised as exceptions to classes derived from some base exception. Others, like C++ in the example above, let the user throw anything they want.

Sometimes they are not syntactically implemented in the language, but instead they are implemented through functions, consider Lua as an example:

function check_one(x)
    if x < 3 then
        error("too little")
    end
    return x
end

function maybe_find()
    local attempts_left = 3
    for _, i in pairs({1, 2, 3}) do
        local ok, res = pcall(check_one, i)
        if ok then
            return res
        end
        if attempts_left > 0 then
            attempts_left = attempts_left - 1
        else
            break
        end
    end
    error("not found")
end

By wrapping a function call with pcall you get an additional return value that is a boolean that indicates whether the function executed successfully or not. You also limit the propagation of errors created with error within that protected call scope.

Signals

As a bonus, let's talk about POSIX signals. You won't see them being used too often for pure error handling, at least not directly. They can be placed somewhere between terminating the process and exceptions as they allow programmer to attempt a recovery, but are not very good at handling scopes and can have only one main entry point for fault branch.

Signals can be also used by the operating system to report selected errors in execution, for example access to invalid memory reference delivers SIGSEGV. Consider an example:

sigjmp_buf env;
void
handle(int sig) {
    siglongjmp(env, 1);
}

int
main(int argc, char* argv[]) {
    signal(SIGSEGV, handle);
    char* ptr = NULL;
    if (sigsetjmp(env, 1))
        ptr = malloc(1);
    printf("%p\n", ptr);
    *ptr = 10;
}

When compiled with all necessary includes and run, it will print out:

(nil)
0x555fb9a7c6b0

Of course, the second address may vary.

The problem with signals is that they require a good amount of attention. Especially when referencing sources over the Internet. Even this example is not portable because it uses signal(2) and not sigaction(2).

Obviously, you are not limited to segmentation fault. You can use SIGABRT with abort(3) or any other signal.

Final Notes

escape route

Anything else? Probably yes. I tried to note similarities between the methods and mention some derivatives, but the chance that I did not miss anything are rather thin. I think that there are some basic characteristics to be observed among all (or some) of them.

With the common goal of reporting an error the first step is usually decoupling successful and failed execution branches. One way involves creating values that are clearly defined as invalid and then dealing with them using usual condition blocks (or statements). The other way involves jumping around the program or unwinding the stack.

The other step is describing the error to the user. This is optional, as in some cases the program or function is answering a general question (e.g., "Did it fail?"). These details can be passed to the user via the actual return value or some secondary output lane like: global variable, standard error output stream or throwing/raising.

This summary may sound obvious but I still think it is worthwhile to think about the reasons that are behind the basic behaviours that we use each day. This is especially interesting from programming language perspective where these days everything is pretty much the same. Maybe a simple change in some assumptions could start a breakthrough. Even if not, then just practicing and gaining knowledge should be good enough of a reason to explore foundations.