Error handling

When writing programs that are intended to be maintained for a long time it is important to design error handling in a way that helps to get to the root causes. Otherwise the app will accumulate workarounds and will get harder to maintain.

This is one way to approach the problem with C++ examples.

I’ll categorize errors based on several properties: their origin, recoverability, and environment. These properties are orthogonal.

The origin indicates whether the error arose from a program bug or from an external event. Developers insert checks in different parts of the program to ensure that designed internal states are maintained. They can reveal program bugs. There are many kinds of them - precondition, postconditions, invariant. There are many kinds of bad external events too - IO, network, or user input errors. I have two origins - invariant violations and runtime errors.

Recoverability describes whether the program can cope with the error. If not, the program explicitly acknowledges this and dies in order to not leave any opportunities to exploit its confusion. The program aborts, a.k.a panics.

I have two environments - production and testing. I’ll generate all combinations of the properties:

for orig in Invariant "Runtime error"
do
    for rec in recoverable fatal
    do
        for environment in production testing
        do
            printf "%*s|%*s|%*s\n" -19 "$orig" -19 "$rec" -19 "$environment"
        done
    done
done
   Source              Recoverability      Environment       How to handle?      Why?
   -----------------------------------------------------------------------------------------------------------------------------------------------------------
1 |Invariant          |recoverable        |production       | Recover, log      | It is convenient for the user. Having such events in logs helps improve the program.
2 |Invariant          |recoverable        |testing          | Abort             | It is harder to miss program crashes, this way it is more likely the bug will be noticed and fixed.
3 |Invariant          |fatal              |production       | Abort             | 
4 |Invariant          |fatal              |testing          | Abort             |
5 |Runtime error      |recoverable        |production       | Recover           |
6 |Runtime error      |recoverable        |testing          | Recover           |
7 |Runtime error      |fatal              |production       | Abort, log        |
8 |Runtime error      |fatal              |testing          | Abort, log        |

Abort means calling std::abort(), while recover means either handling the problem in place or propagating it downstream via the mechanism of choice - exceptions, error codes, or std::expected - and handling it there.

There is a common term for “checks in different parts of the program…” - asserts. C++ has a standard assert macro. The check inside it compiles to no-op when you compile the program with the -DNDEBUG flag, but this macro is hardly configurable any further than that. Therefore projects often have their own set of assert macros.

For 1-4 I use the following macros, which are configured in CMake depending on the target environment.

LOCALLY_ASSUME(bool-expr [, what-msg])
LOCALLY_ASSUME_NOTHROW(expr)
LOCALLY_ASSUME_UNREACHABLE()

LOCALLY_ASSERT(bool-expr [, what-msg])
LOCALLY_ASSERT_UNREACHABLE()

“ASSUME” macros are used in cases 1-2. The LOCALLY_ASSUME macro is a boolean expression and is used the following way: if (LOCALLY_ASSUME(some-test)) { ... } else { recover }. In testing environment the macro imbues an std::abort() call, so the expression never returns. The LOCALLY_ASSUME_NOTHROW is an expression that aborts on exceptions in the testing environment, but in production the macro rethrows the exception giving the downstream a chance to recover.

“ASSERT” macros are used in cases 3-4. They always abort.

I don’t have an equivalent to the standard assert macro (debug assert is a more general term), because I don’t need it in my app yet. A debug assert is usually applicable when the check resides on a hot path and the overhead is unacceptable. This assert is a little insidious because it allows UB in production but not in the testing environment. For example std::vector<T, ...>::operator[](size_t index) has a debug assert to ensure the index is inside the bounds, in production it will not catch going out of bounds and introduce UB. Here is another example: signed integer overflow in C++ is UB, whereas in Rust it isn’t, because the signed integer type there is implemented in two’s complement arithmetic with a debug_assert!(!carry). In production the assert is no-op, but the remaining behavior is still defined, and the program has a chance of catching the error somewhere else downstream. I think when using debug asserts one should try to find a safe fallback behavior, but I acknowledge that it might be unachievable in every instance.

In cases 5-8 I use exceptions with the base class defined as:

class LoError : public std::exception {
public:
    char const* what() const noexcept override;

    ///! User friendly description; can be empty.
    std::string_view const& get_display() const noexcept;
    
    ///! The exception that caused us; can be nullptr.
    std::exception_ptr const& get_source() const noexcept;

    ///! The location where the error was built. Can be nullptr.
    std::source_location const* get_location() const noexcept;
};

One interesting detail to note here is get_source(), which enables tracking the full causal chain. It is somewhat similar to a backtrace, except it captures the failure chain in the domain space rather than the code space. With some utility functions, the chain can be parsed and logged.

To summarize, it is useful to distinguish cases 1-4 from cases 5-8. Before suppressing or throwing an exception, one is encouraged to first try to get to the root cause; otherwise, one risks hiding the real issue behind an additional layer. Furthermore, a local fix in cases 1-4 might work for this particular manifestation of the bug, but other parts of the program affected by the same bug will remain unhandled and fail.

Visitor Counter