Error Handling: errno to Result

Every syscall can fail. Every allocation can return NULL. How a language handles errors defines how reliable the software built with it can be. C gives you conventions. Rust gives you a type system. This chapter covers both, from the humble return code to the ? operator.

C: The Return Code Convention

The simplest C error-handling pattern: return 0 for success, non-zero for failure.

/* retcode.c */
#include <stdio.h>

int parse_positive_int(const char *s, int *out)
{
    int val = 0;
    if (!s || !out)
        return -1;  /* invalid argument */

    for (const char *p = s; *p; p++) {
        if (*p < '0' || *p > '9')
            return -2;  /* not a digit */
        val = val * 10 + (*p - '0');
    }

    *out = val;
    return 0;  /* success */
}

int main(void)
{
    int val;
    int rc;

    rc = parse_positive_int("42", &val);
    if (rc == 0)
        printf("Parsed: %d\n", val);
    else
        printf("Error: %d\n", rc);

    rc = parse_positive_int("12ab", &val);
    if (rc == 0)
        printf("Parsed: %d\n", val);
    else
        printf("Error: %d\n", rc);

    return 0;
}

Compile and run:

gcc -Wall -o retcode retcode.c && ./retcode

Output:

Parsed: 42
Error: -2

This works for small programs. But notice: the caller must remember to check the return value. The compiler will not warn if they forget.

C: errno -- The Global Error Variable

POSIX functions return -1 (or NULL) on failure and set errno to indicate what went wrong.

/* errno_demo.c */
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    int fd = open("/nonexistent/file.txt", O_RDONLY);
    if (fd == -1) {
        printf("errno = %d\n", errno);
        printf("strerror: %s\n", strerror(errno));
        perror("open");  /* prints: open: No such file or directory */
    }

    errno = 0;  /* manual reset */

    FILE *f = fopen("/etc/shadow", "r");
    if (!f) {
        perror("fopen /etc/shadow");
    }

    return 0;
}

Compile and run:

gcc -Wall -o errno_demo errno_demo.c && ./errno_demo

The flow:

  open("/nonexistent/file.txt", O_RDONLY)
      |
      +-- kernel returns error
      +-- glibc sets errno = ENOENT (2)
      +-- returns -1 to caller
      +-- caller checks return value, reads errno for detail

Caution: errno is thread-local in modern C (C11 / POSIX), but it is still fragile. Any function call between the failing call and reading errno can overwrite it. Always check errno immediately after the call that failed.

C: The -1 / NULL / errno Pattern

Most POSIX and standard library functions follow one of these patterns:

+---------------------------+------------------+------------------+
| Function returns          | On success       | On failure       |
+---------------------------+------------------+------------------+
| int (file descriptor)     | >= 0             | -1, sets errno   |
| pointer                   | valid pointer    | NULL, sets errno |
| ssize_t (byte count)      | >= 0             | -1, sets errno   |
| int (status)              | 0                | -1, sets errno   |
+---------------------------+------------------+------------------+

Not all functions follow this. getchar() returns EOF on failure. pthread_create returns the error number directly (not through errno). You must read the man page for every function you call.

Caution: Some functions (like strtol) have ambiguous return values. A return of 0 might mean "parsed zero" or "parse failed." You must set errno = 0 before calling and check it afterward.

C: The goto cleanup Pattern

When a function acquires multiple resources, you need to release them all on any error path. The goto pattern is standard practice in C -- and is used extensively in the Linux kernel.

/* goto_cleanup.c */
#include <stdio.h>
#include <stdlib.h>

int process_file(const char *path)
{
    int ret = -1;
    FILE *f = NULL;
    char *buf = NULL;

    f = fopen(path, "r");
    if (!f) {
        perror("fopen");
        goto cleanup;
    }

    buf = malloc(4096);
    if (!buf) {
        perror("malloc");
        goto cleanup;
    }

    if (!fgets(buf, 4096, f)) {
        if (ferror(f)) {
            perror("fgets");
            goto cleanup;
        }
        buf[0] = '\0';
    }

    printf("First line: %s", buf);
    ret = 0;  /* success */

cleanup:
    free(buf);   /* free(NULL) is safe */
    if (f)
        fclose(f);
    return ret;
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <file>\n", argv[0]);
        return 1;
    }
    return process_file(argv[1]) == 0 ? 0 : 1;
}

Compile and run:

gcc -Wall -o goto_cleanup goto_cleanup.c && echo "hello world" > /tmp/test.txt && ./goto_cleanup /tmp/test.txt

The goto-cleanup pattern:

  function entry
      |
      allocate resource A ---fail---> goto cleanup
      |
      allocate resource B ---fail---> goto cleanup
      |
      do work -------------fail---> goto cleanup
      |
      success: ret = 0
      |
  cleanup:
      free B (if allocated)
      free A (if allocated)
      return ret

Driver Prep: The Linux kernel style guide explicitly endorses goto for error handling. You will see this pattern in virtually every kernel function that acquires resources.

Try It: Modify process_file to also malloc a second buffer for a processed output. Add the proper cleanup. What happens if you forget to free the second buffer on the error path?

Rust: Result<T, E>

Rust replaces all of the above with a single type:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

You cannot ignore it. If a function returns Result, the compiler warns you if you do not handle it.

// result_basic.rs
use std::fs;
use std::io;
fn read_first_line(path: &str) -> Result<String, io::Error> {
    let contents = fs::read_to_string(path)?;
    let first = contents.lines().next().unwrap_or("").to_string();
    Ok(first)
}

fn main() {
    match read_first_line("/tmp/test.txt") {
        Ok(line) => println!("First line: {}", line),
        Err(e) => eprintln!("Error: {}", e),
    }

    match read_first_line("/nonexistent/file.txt") {
        Ok(line) => println!("First line: {}", line),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Compile and run:

rustc result_basic.rs && ./result_basic

The ? Operator

The ? operator is Rust's answer to the goto-cleanup pattern. It does three things:

  1. If the Result is Ok(val), unwrap val and continue.
  2. If the Result is Err(e), convert e into the function's error type and return early.
  3. All cleanup happens automatically via Drop.
// question_mark.rs
use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn first_line_length(path: &str) -> Result<usize, io::Error> {
    let file = File::open(path)?;          // returns Err if open fails
    let reader = BufReader::new(file);
    let mut line = String::new();
    reader.take(4096).read_line(&mut line)?; // returns Err if read fails
    Ok(line.trim_end().len())
}

fn main() {
    match first_line_length("/tmp/test.txt") {
        Ok(len) => println!("Length: {}", len),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Compare the flow to C's goto:

  C (goto cleanup)                Rust (? operator)
  ----------------                ------------------
  f = fopen(path)                 let file = File::open(path)?;
  if (!f) goto cleanup;           // auto-returns Err on failure

  buf = malloc(4096);             let mut line = String::new();
  if (!buf) goto cleanup;         // String manages its own memory

  fgets(buf, 4096, f);            reader.read_line(&mut line)?;
  if error goto cleanup;          // auto-returns, auto-cleans up

  cleanup:                        // no cleanup block needed --
    free(buf);                    // Drop runs automatically
    fclose(f);

Option -- Nullable Without NULL

C uses NULL for "no value." Rust uses Option<T>:

// option_demo.rs
fn find_first_negative(nums: &[i32]) -> Option<usize> {
    for (i, &n) in nums.iter().enumerate() {
        if n < 0 {
            return Some(i);
        }
    }
    None
}

fn main() {
    let data = [3, 7, -2, 5, -8];

    match find_first_negative(&data) {
        Some(idx) => println!("First negative at index {}", idx),
        None => println!("No negatives found"),
    }

    if let Some(idx) = find_first_negative(&data) {
        println!("Value: {}", data[idx]);
    }

    let empty: &[i32] = &[];
    println!("In empty: {:?}", find_first_negative(empty));
}

Compile and run:

rustc option_demo.rs && ./option_demo

Rust Note: Option<T> has zero overhead for pointer types. The compiler uses the null representation internally, so Option<&T> is the same size as a raw pointer. This is called the "null pointer optimization."

Custom Error Types

Real programs have multiple error sources. In Rust, you define an enum that implements std::error::Error:

// custom_error.rs
use std::fmt;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    InvalidArg(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "I/O error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
            AppError::InvalidArg(msg) => write!(f, "Invalid argument: {}", msg),
        }
    }
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn read_number_from_file(path: &str) -> Result<i64, AppError> {
    if path.is_empty() {
        return Err(AppError::InvalidArg("empty path".to_string()));
    }
    let contents = std::fs::read_to_string(path)?;  // io::Error -> AppError
    let num: i64 = contents.trim().parse()?;         // ParseIntError -> AppError
    Ok(num)
}

fn main() {
    match read_number_from_file("/tmp/number.txt") {
        Ok(n) => println!("Number: {}", n),
        Err(e) => eprintln!("Error: {}", e),
    }

    match read_number_from_file("") {
        Ok(n) => println!("Number: {}", n),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Compile and run:

echo "42" > /tmp/number.txt
rustc custom_error.rs && ./custom_error

The From implementations let the ? operator automatically convert different error types into your unified AppError.

Why C Error Handling Leads to Bugs

Consider this real-world pattern:

/* bug_demo.c -- spot the bugs */
#include <stdio.h>
#include <stdlib.h>

char *load_config(const char *path)
{
    FILE *f = fopen(path, "r");
    if (!f)
        return NULL;

    char *buf = malloc(1024);
    if (!buf)
        return NULL;  /* BUG: f is leaked! */

    if (!fgets(buf, 1024, f)) {
        free(buf);
        return NULL;  /* BUG: f is leaked again! */
    }

    fclose(f);
    return buf;
}

int main(void)
{
    char *cfg = load_config("/tmp/test.txt");
    if (cfg) {
        printf("Config: %s", cfg);
        free(cfg);
    }
    return 0;
}

Two resource leaks in a 15-line function. This is everywhere in C codebases. The language simply does not help.

Try It: Fix the bugs in load_config using the goto-cleanup pattern. Then write the equivalent in Rust and observe that the resource leaks are impossible.

Using thiserror and anyhow (Cargo ecosystem)

For real Rust projects, two crates simplify error handling enormously.

thiserror -- for library code, auto-generates Display and From:

#![allow(unused)]
fn main() {
// In Cargo.toml: thiserror = "1"
use thiserror::Error;

#[derive(Error, Debug)]
enum DataError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("value {0} out of range {1}..{2}")]
    OutOfRange(i64, i64, i64),
}
}

anyhow -- for application code, wraps any error into a single type:

// In Cargo.toml: anyhow = "1"
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;
    Ok(contents)
}

fn main() -> Result<()> {
    let cfg = load_config("/tmp/test.txt")?;
    println!("Config: {}", cfg.trim());
    Ok(())
}

Error Handling Decision Tree

  Are you writing a library?
      |
      YES --> Use thiserror (or manual impl)
      |         - Callers need to match on specific errors
      |
      NO --> Are you writing an application?
              |
              YES --> Use anyhow
              |         - Just print errors and bail
              |         - Use .context() for readable messages
              |
              (learning / small scripts) --> Use Box<dyn Error>

Mapping C errno to Rust

When calling C functions from Rust (via FFI), convert errno to a Rust error using std::io::Error:

// errno_to_rust.rs
fn main() {
    let result = std::fs::File::open("/nonexistent/path");
    match result {
        Ok(_) => println!("opened"),
        Err(e) => {
            println!("Error: {}", e);
            println!("OS error code: {:?}", e.raw_os_error());
            println!("Error kind: {:?}", e.kind());
        }
    }
}

Output:

Error: No such file or directory (os error 2)
OS error code: Some(2)
Error kind: NotFound

io::Error wraps errno values and maps them to the ErrorKind enum. This is how Rust bridges the C world.

Knowledge Check

  1. What happens in C if you call two POSIX functions in a row and only check errno after the second one?

  2. In Rust, what does the ? operator do when it encounters an Err value?

  3. Why does Rust not need a goto-cleanup pattern for resource management?

Common Pitfalls

  • Forgetting to check return values in C. The compiler will happily let you ignore the return value of read(), write(), or close(). Bugs hide here for years.
  • Checking errno after an intervening call. Even printf can overwrite errno. Read it immediately after the failing call.
  • Using unwrap() in Rust production code. It panics on Err. Use ? or match instead. Reserve unwrap() for cases where failure is truly impossible.
  • Ignoring #[must_use] warnings. Rust marks Result as #[must_use]. If you see a warning about an unused Result, you are ignoring an error.
  • Confusing Option with Result. Use Option for "might not exist" and Result for "might fail." Do not use Result<T, ()> when you mean Option<T>.