Error handling
Fri, 11 Sep 2020 13:19:48 -0000
Some thoughts about error handling in software development.

Aims

Code consists of chunks of code (functions, member methods ...)

General

There are various patterns and features in recent languages that address the problem of error handling. However, in many cases you are forced to rely on the most basic ones, e.g. in case you deal with a language that does not provide for special features like C, or you are forced to interact with an API that does not support them, like an operation system API. Ultimately, all error handling at its core relies on few simple patterns:
if you got code like:

bool get_data(char const *path, int *recv_buffer, size_t num_entries) {

int fh = open(path, O_READ);

if(0 > fh) {

return false;

}

const size_t bytes_to_read = num_entries * sizeof(int);

size_t bytes_read = read(fh, recv_buffer, bytes_to_read);

close(fh);

if(bytes_to_read != bytes_read) {

return E_NOT_ENOUGH_DATA_FOUND;

}

return true;

}

...

int recv_buffer[255];

if(! get_data("data.dat", reccv_buffer, 255)) {

fprintf(stderr, "Could not retrieve data from file\n");
exit(EXIT_FAIL);
}

for(size_t i = 0; 255 > i; ++i) {

process_int(recv[i]);

}

...

If your program fails to execute and exits with the message Could not retrieve data from file you might wonder why it was not able to retrieve the data - was the file missing? Weren't there enough data in the file? You could use the return value of get_data to return more information about the error. This is totally fine if there is a finite set of error causes. Your code might then transform into something like this:

...

void process_ints(int const *buffer, const size_t num_ints) {

for(size_t i = 0; num_ints > i; ++i) {

process_int(buffer[i]);

}

}

...

switch(get_data("data.dat", recv_buffer, 255)) {

case 0:
process_ints(recv_buffer, 255);
break;

case ENOENT:

fprintf(stderr, "No such file or directory");
break;

default:

assert(! "MUST NEVER HAPPEN");

};

...
(Always remember to make your switches fail fast in case of unexpected values - always add an appropriate default: branch! ) Note that we don't want to define ever new error codes - there are plenty already defined for various situations in C / POSIX, and probably some that suit your needs. It standardises the code and also simplifies writing your code. If you implement get_data without using predefined error codes, a portion of it would look like

int get_data(char const *path, int *recv_buffer, size_t num_entries) {

fh = open(path, O_RDONLY);

if(0 > fh) {

return E_FILE_NOT_FOUND;

}

const size_t bytes_to_read = num_entries * sizeof(int);

size_t bytes_read = read(fh, recv_buffer, bytes_to_read);

close(fh);

if(bytes_to_read != bytes_read) {

return E_NOT_ENOUGH_DATA_FOUND;

}

return 0;

}

...

switch(get_data("data.dat", recv_buffer, 255)) {

case 0:
process_ints(recv_buffer, 255);
break;

case E_FILE_NOT_FOUND:

fprintf(stderr, "No such file or directory");
break;

case E_NOT_ENOUGH_DATA:

fprintf(stderr, "Not enough data in file\n");
break;

default:

assert(! "MUST NEVER HAPPEN");

};

...

Apart from the fact that somewhere in your code you need to define these additional constants, bloating your code, you also have to add an extra translation layer - and your error code might be misleading: What if the file exists, but is a directory? If you instead rely on the predefined error codes, you might just implement the same portion of code like

int get_data(char const *path, int *recv_buffer, size_t num_entries) {

fh = open(path, O_RDONLY);

if(0 > fh) {

return errno;

}

const size_t bytes_to_read = num_entries * sizeof(int);

size_t bytes_read = read(fh, recv_buffer, bytes_to_read);

close(fh);

if(bytes_to_read != bytes_read) {

return ENODATA;

}

return 0;

}

...

int retval = get_data("data.dat", recv_buffer, 255);

if(0 > retval) {

fprintf(stderr, "Could not retrieve data: %s\n", strerror(retval));

}

...

The standardised codes are well supported:
The value of avoiding confusing / surprising the users of your code cannot be underestimated and is often referred to as 'principle of least surprise'. You could even redesign the get_data to most closely resemle that of read(2) and return the number of bytes read in case of success:

int get_data(char const *path, int *recv_buffer, size_t int_to_read) {

fh = open(path, O_RDONLY);

if(0 > fh) {

return errno;

}

const size_t bytes_to_read = num_entries * sizeof(int);

size_t bytes_read = read(fh, recv_buffer, bytes_to_read);

close(fh);

if(bytes_to_read != bytes_read) {

return ENODATA;

}

return bytes_read / 4;

}
However, the best approach is to just keep the errror local to the function it appears in. For instance, get_data could be implemented like

size_t get_data(char const *path, int *recv, size_t recv_size_ints) {

fh = open(path, O_RDONLY);

if(0 > fh) {

fprintf("Could not open file %s: %s", path, strerror(errno));
return 0;

}

const size_t bytes_to_read = recv_size_ints * sizeof(int);

size_t bytes_read = read(fh, recv_buffer, bytes_to_read);

close(fh);

if(0 > bytes_read) {

fprintf(stderr, "Could not read from file %s: %s\n", path,
strerror(errno));

return 0;

}

if(0 == bytes_read) {

fprintf(stderr, "No data received\n");

}

return bytes_read / 4;

}

...

int recv[255] = {0};

const size_t num_ints = get_data("data.dat", recv, 255);

for(size_t i = 0; num_ints > i; ++i) {

process_int(recv[i]);

}

...
This way, the caller needs not even bother whether an error occured in the lower layer, because it is contained within the lower layer.