Errata

Errata is an error reporting class. The goals are

  • Very fast in the success case.

  • Easy to accumulate error messages in a failure case.

An assumption is that error handling is always intrinsically expensive, in particular if errors are reported or logged. Therefore in the error case it is better to be comprehensive and easy to use. Conversely, the success case should be optimized for performance because it is all overhead.

If errors are to be reported, they should be as detailed as possible. Errata supports by allowing error messages to accumulate. This means detailed error reports do not require passing large amounts of context to generic functions so that errors there can be reported in context. Instead the calling functions can add their local details to the error stack, passing these back to their callers. The end result is both what exactly went wrong at the lowest level and the context in which the failure occurred. E.g., for a failure to open a file, the file open logic can report the direct error (e.g. “Permission denied”) and the path, while the higher level function such as a configuration file parser, can report it was the configuration file open that failed.

Definition

#include <swoc/Errata.h>
class Errata

Reference documentation.

Usage

The default Errata constructor creates an empty, successful state. This is extremely fast. An error state is created by constructing an instance with an annotation and optional error code and severity.

The basic interface is provided by the Errata::note() method and variants thereof. This adds a message along with a optional severity. Using Errata requires defining the default severity and “failure” severity. The failure severity is the severity for which an Errata is considered to be an error. There is also a “filter” severity - annotations that have a severity that is less than this value are not added but are ignored. This can be overridden so that an application can dynamically set the value to capture only annotations that are of sufficient interest.

An Errata instance also carries a error code which is intended to distinguish among errors of the same severity in an easy to examine way. For interoperability with standard error handling std::error_code is used as the identifier. This allows constructing an instance from the error return of system functions and for callers to check if the error is really an error. Note, however, that severity and error code are independent.

Errata provides the Rv template which is intended for passing back return values or error reports. The template parameter is the return type, to which is added an Errata instance. The Rv instance implicitly converts to the return type so that if a function is changed from returning T to Rv<T> the callers do not need to be changed.

Severity

The severity support has been a bit of an issue. Although the support seems a bit convoluted, in practice users of Errata tend to have an already defined severity scale. The goal is to be able to integrate that existing scale easily in to Errata.

For the purposes of the unit testing and example code the following is included to define the severity levels. This is modeled as usual on the syslog severity levels.

static constexpr swoc::Errata::Severity ERRATA_DBG{0};
static constexpr swoc::Errata::Severity ERRATA_DIAG{1};
static constexpr swoc::Errata::Severity ERRATA_INFO{2};
static constexpr swoc::Errata::Severity ERRATA_WARN{3};
static constexpr swoc::Errata::Severity ERRATA_ERROR{4};

It is expected the application would already have the equivalent definitions and would not need any special ones for Errata. The severity values are initialized during start up.

These levels can also be named. It is presumed the severity values are zero based and compact and therefore the names can be provided in an instance of code:MemSpan<TextView>. Severity values outside the span are printed as numbers. For the unit tests the names are declared as a global value.

std::array<swoc::TextView, 5> Severity_Names { {
  "Debug", "Diag", "Info", "Warn", "Error"
}};

The initialization is done in test_Errata_init which is called from main.

void test_Errata_init() {
  swoc::Errata::DEFAULT_SEVERITY = ERRATA_ERROR;
  swoc::Errata::FAILURE_SEVERITY = ERRATA_WARN;
  swoc::Errata::SEVERITY_NAMES = swoc::MemSpan<swoc::TextView>(Severity_Names.data(), Severity_Names.size());
}

If there is no external initialization, then there are three levels of severity 0..2 with the names “Info”, “Warning”, and “Error”. The default severity is “Error” (2) with a failure threshold of 1 (“Warning”), that is a severity of “Warning” or “Error” marks a failure.

By default annotations do not have a severity, that is a property of the Errata. However a severity can be added to an annotation. If this is done the the severity of the Errata is updated to that severity if it is larger (more severe) than the current severity. Annotations can be filtered by adjusting the value of swoc::Errata::FILTER_SEVERITY. If a severity is provided with an annotation (via some variant of the note method) then this is checked against FILTER_SEVERITY and if it is less than that the annotation is not added. This enables an application to dynamically control the verbosity of errors without changing how they are generated. The Errata serverity is updated as appropriate even if the annotation is discarded.

Examples

There are two fundamental cases for use of Errata. The first is the leaf function that creates the original Errata instance. The other case is code which calls an Errata returning function. Loading a configuration file will be used as an example.

A leaf function could be one that, given a path, opens the file and loads it in to a std::string using file::load.

Errata load_file(swoc::path const& path) {
   std::error_code ec;
   std::string content = swoc::file::load(path, ec);
   if (ec) {
      return Errata(ec, ERRATA_ERROR, "Failed to open file {}.", path);
   }
   // config parsing logic
}

The call site might look like

Errata load_config(swoc::path const& path) {
   if (Errata errata = this->load_file(path) ; ! errata.is_ok()) {
      return std::move(errata.note("While opening configuration file."));
   }
   // ... more code.

Printing the result would look like:

Error Failed to open file thing.txt EPERM [1].
   While opening configuration file.

While some functions will return Errata the more common case is to use Rv to carry back either a value (successful) or an error report (failure).

Extending

There are some variants of note which are intended for use by “helper” methods. The most common example would be the desire to have a function that creates an “Error” level Errata. This could be done with

template <typename... Args>
Errata &
NoteInfo(Errata &errata, std::string_view fmt, Args... args) {
  return errata.note_v(ERRATA_INFO, fmt, std::forward_as_tuple(args...));
}

This can then be used on an instance like:

NoteInfo(errata, "Looking at {} values.", count);

Design Notes

I have carted around variants of this class for almost two decades, the evolution being driven primarily by the evolving capabilities of C++ rather than a fundamental change in the design philosophy. The original impetus was as noted in the introduction, to be able to generate a very detailed and thorough error report on a failure, which as much context as possible. In some sense, to have a stack trace without actually crashing.

Recently (Jan 2022) I’ve restructured Errata based on additional usage experience.

  • The error code was removed for a while but has been added back. Although not that common, it is not rare that having a code is useful for determining behavior for an error. The most common example is EAGAIN and equivalent - the caller might well want to log and try again rather than immediately fail. To be most useful the error code was made to be std::error_code which covers both C++ error codes and standard “errno” errors.

  • The error code and severity was moved from messages to the Errata instance. The internals were already promoting the highest severity of the messages to be the overall severity, and error codes for messages other than the front message seemed useless. However, this was changed again due to user request so that annotation serverity is available but optional. This can be used for additional display (where annotations are printed with the severity, if present) and for filtering (only sufficiently severe annotations are added).

  • The reference counting was removed. With the advent of move semantics there is much less need to make cheap copies with copy on write support. Because of the change from general memory allocation to use of an internal MemArena it is no longer possible to share messages between instances which makes the copy on write use of the reference count useless. For these two reasons reference was removed along with any copying. Use must either explicitly copy via the note method or “move” the value, which intermediate functions must now use. I think this is worth that cost because there are edge cases that are hard to handle with reference counting which don’t arise with pure move semantics.

  • Annotations are now appended instead of prepended. A big change but in many cases the order was already messed up because I think this was assumed but sometimes accomodated. It’s debatable which is the better style but overall append makes some small bits cleaner.