Error handling

leet offers utilities to deal with two groups of errors: recoverable and unrecoverable.

An error is recoverable when it can be treated by your code and execution is allowed to continue. For example if you’re making a REPL, getting a wrong instruction is not the end of the world: your code realizes it can’t execute it, lets the user know, and waits for the next instruction.

An error is unrecoverable when it is impossible to continue execution after it happens. For example if your code tries to access the 100th element of a slice that only has capacity for 10 elements, execution cannot continue.

Note

Unrecoverable errors

When you realize something went horribly wrong and your program cannot continue, you should exit with a panic. A panic must be triggered with a readable message describing the error.

Using our slice example from before:

Safe slice access that triggers a panic if the index is out of bounds.
void*
slice_sat(struct slice* p, size_t idx)
{
        size_t offset = p->_el_size * idx;

        if (offset >= p->_capacity)
            panic("access out of bounds: slice capacity is %u but offset was %u.", p->_capacity, offset);

        return p->_data + offset;
}

The panic will print your message to stderr along with where it happened, and exit with a non-zero code.

Panic location and readable error message.
panicked at include/ds/slice.h:190:
slice access out of bounds: slice capacity is 10 but offset was 100.

Recoverable errors

Reporting errors

When a function fails with an error that does not require stopping execution, you may report the error to the caller so it can be handled. It is equivalent to a throw in languages that have exceptions. Functions that can report a recoverable error should accept a handle to an error pointer as the last parameter.

Using our REPL example:

Binary operation that reports an error if there is only one operand.
void
bin_op(struct slice* s, char op, struct error** error)
{
        if (s->_ptr < s->_el_size * 2)
        {
                error_set(error, LINVAL, "%c is a binary operation but the stack only has one value.", op);
        }
        else
        {
                // We have at least two values, execute the operation.
                // ... snip ...
        }
}

Attention

Reporting an error allocates memory and transfers its ownership to the caller. That means the caller must not allocate memory for the report, but must free the memory after handling it.

Handling errors

To get error reports you must pass a valid pointer to a function’s error parameter. Calling a function with a valid error pointer and checking if the pointer was set is equivalent to a try-catch in languages that support exceptions. The error pointer will be set if the function encounters an error, or left unchanged if the function executes successfully.

If the pointer is set, the function encountered an error.
// ... snip ...
struct error* error = NULL;

bin_op(stack, op, &error);
if (error != NULL)
{
        // Let the user know the operation could not be executed.
        // ... snip ...
        error_del(&error);
}

If an error can be safely ignored, you may call the function with a null pointer. It is equivalent to a try-catch with an empty catch block in languages that have exceptions. This does not mean the error will not be handled, it means the error can be handled by doing nothing.

A null pointer signifies that the error is handled by doing nothing.
bool
update(int id, struct error** error)
{
        if (!exists(id))
        {
                set_error(error, LNOENT, "no entity with the given id: %d", id);
                return false;
        }
        // ... snip ...
}

void
update_if_exists(int id)
{
        update(id, NULL);
}

When nesting functions that can report errors it is important to not reuse the error pointer provided to the parent function, as it may be null. Instead you should create a temporary error pointer.

Call the child function with a temporary error pointer.
void
parent(struct error** error)
{
        struct error* tmp_error = NULL;
        child(&tmp_error);
        if (tmp_error != NULL)
        {
                // Handle error from the child function.
                // ... snip ...
                // Call error_del or error_propagate.
        }
}

Propagating errors

When a caller cannot handle an error from a nested function call, you may propagate the error upwards with error_propagate. It is equivalent to calling a function that throws exceptions outside of a try-catch block or catching and re-throwing in languages that have exceptions. When propagating an error you don’t need to worry about ownership, error_propagate is smart enough to free the error in cases where the caller chose to ignore it or transfer ownership otherwise.

This error cannot be handled here but may be handled by the caller of parse, propagate it.
bool
parse(const char* src, struct slice* tokens, struct error** error)
{
        struct error* tmp_error = NULL;

        // ... snip ...
        parse_ident(src, ptr, tokens, &tmp_error);
        if (tmp_error != NULL)
        {
                // We need an identifier but couldn't parse one,
                // there's nothing we can do to fix that.
                error_propagate(&tmp_error, error);
                return false;
        }
}

If all you need is to propagate an error and return, error_bubble can help avoid repeating the if statement from the previous example:

Propagate and return false immediately on error.
bool
parse(const char* src, struct slice* tokens, struct error** error)
{
        struct error* tmp_error = NULL;

        // ... snip ...
        parse_ident(src, ptr, tokens, &tmp_error);
        error_bubble(&tmp_error, error, false);
}

Performance

Reporting errors requires a memory allocation and string formatting. For more performant reports you may avoid the string formatting with error_set_literal, or fallback to integer error codes.

TODO

Todo

  • Safe strings.

Todo

  • Warnings and assertions when error functions are used incorrectly.

API

#include <error.h>

Enums

enum error_code

High level description of an error.

Values:

enumerator LINVAL

Invalid value.

Handle

struct error

Information about an error.

Public Members

enum error_code code

High level description.

char *message

Readable message.

Functions

void error_set(struct error **p, enum error_code code, const char *format, ...)

Reports a recoverable error with a formatted message.

Functions that can fail should accept a handle to an error pointer as their last parameter.

This function creates an error and writes it to the given handle, transfering ownership to the caller. After handling the error, the caller must call error_del or error_propagate

If you don’t need a formatted string, use error_set_literal instead.

Parameters:
  • p – Handle to write the error to.

  • error_code – High level description of the error.

  • format – Format string for the readable message.

  • ... – Parameters for the format string.

void error_set_literal(struct error **p, enum error_code code, const char *message)

Reports a recoverable error with a message.

This is equivalent to error_set but uses a given message instead of formatting a string.

See also

error_set

Parameters:
  • p – Handle to write the error to.

  • error_code – High level description of the error.

  • message – Readable message.

void error_del(struct error **p)

Deallocates the memory backing an error created by error_set or error_set_literal.

Parameters:
  • p – Handle to the error to deallocate.

bool error_propagate(struct error **src, struct error **dst)

Propagates a recoverable error to the destination, if it happened.

When nesting functions that can fail, you must create a temporary error for the subfunction call (the error handle passed to the parent function could be NULL).

This function can propagate the error to the parent function, while also returning whether or not an error happened.

int
can_error(struct error** error)
{
     struct error* tmp_error = NULL;

     also_can_error(&tmp_error);
     if (error_propagate(&tmp_error, error))
             // The sub function failed.
             // Possibly clean up, then return from this function.
             // The error propagates upward.
             return -1;

     // ... snip ...
}

When all you need is to propagate the error and return immediately error_bubble may be used to simplify this pattern.

Parameters:
  • src – The error to propagate.

  • dst – Handle to propagate the error to.

Returns:

Whether or not an error occurred.

Macros

panic(...)

Reports an unrecoverable error and exits the program.

Exiting the program due to an unrecoverable error should be done through this macro. Should be called with a readable error message.

Parameters:
  • format – Error message or format string.

  • ... – Parameters for the format string.

error_bubble(src, dst, ret)

Propagates a recoverable error immediately, returning a value from the current function.

It is a common pattern to check if an error occurred, and return from the current function if it did. In some cases there’s no cleanup needed on the parent function and all you have to do is propagate the error and return. This macro turns that pattern into a single invocation.

From this:

int
can_error(struct error** error)
{
     struct error* tmp_error = NULL;

     also_can_error(&tmp_error);
     if (error_propagate(&tmp_error, error))
             return -1;

     // ... snip ...
}

To this:

int
can_error(struct error** error)
{
     struct error* tmp_error = NULL;

     also_can_error(&tmp_error);
     error_bubble(&tmp_error, error, -1);

     // ... snip ...
}

If you don’t need to return a value, use error_bubble_void instead.

Parameters:
  • src – Source error.

  • dst – Destination to propagate the error to.

  • ret – Return value of the function.

error_bubble_void(src, dst)

Propagates a recoverable error immediately, returning from the current function.

This is equivalent to error_bubble but doesn’t return a value.

See also

error_bubble

Parameters:
  • src – Source error.

  • dst – Destination to propagate the error to.