Background

Error Handling

4 min read

Prerequisites: Structs and Enums
Next: Collections

Rust does not have exceptions. Instead, it has two built-in enum types — Option<T> for "maybe a value" and Result<T, E> for "success or failure" — and the ? operator for propagating errors up the call stack. The compiler forces you to handle both cases, making error paths explicit and type-checked.


Option<T> — Representing "Maybe a Value"

Python uses None to mean "no value." Rust wraps optional values in Option<T> — you can't accidentally use a None as if it were a real value because the type system prevents it.

def find_threshold(name: str) -> float | None:
    thresholds = {"f1": 0.85, "precision": 0.90}
    return thresholds.get(name)   # returns None if not found

t = find_threshold("f1")
if t is not None:
    print(t + 0.05)   # Python won't stop you using None + 0.05 until runtime
fn find_threshold(name: &str) -> Option<f64> {
    let thresholds = std::collections::HashMap::from([
        ("f1", 0.85),
        ("precision", 0.90),
    ]);
    thresholds.get(name).copied()   // Option<f64>: Some(0.85) or None
}

// Must unwrap explicitly — compiler won't let you use Option<f64> as f64
match find_threshold("f1") {
    Some(t) => println!("{}", t + 0.05),
    None    => println!("not found"),
}

Common Option Methods

let t: Option<f64> = find_threshold("f1");

// unwrap_or — provide a default (like dict.get(key, default))
let value = t.unwrap_or(0.0);

// map — transform the inner value if it is Some
let adjusted = t.map(|v| v + 0.05);

// and_then — chain operations that might also return None
let result = t.and_then(|v| if v > 0.8 { Some(v) } else { None });

// is_some / is_none
if t.is_some() { println!("found"); }

// unwrap — panics if None; only use when None is truly impossible
let must_exist = t.unwrap();

// expect — panics with a custom message; better than unwrap in diagnostics
let must_exist = t.expect("threshold must be configured");

Result<T, E> — Representing "Success or Failure"

Python uses exceptions. Rust uses Result<T, E> — success is Ok(value), failure is Err(error). The compiler forces you to handle both cases; you cannot ignore an error silently.

def parse_int(s: str) -> int:
    return int(s)   # raises ValueError on bad input

try:
    n = parse_int("42")
except ValueError as e:
    print(f"failed: {e}")
fn parse_int(s: &str) -> Result<i64, std::num::ParseIntError> {
    s.parse::<i64>()  // returns Ok(42) or Err(ParseIntError)
}

match parse_int("42") {
    Ok(n)  => println!("got {n}"),
    Err(e) => println!("failed: {e}"),
}

The ? Operator — Propagate Errors Up the Call Stack

def load_config(path: str) -> dict:
    with open(path) as f:       # raises FileNotFoundError
        return json.load(f)     # raises json.JSONDecodeError
    # exceptions bubble up automatically
use std::fs;

fn load_config(path: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    let text = fs::read_to_string(path)?;   // ? returns Err early if it fails
    let cfg  = serde_json::from_str(&text)?; // same
    Ok(cfg)
}

? unwraps Ok(v) and continues, or returns Err(e) immediately from the current function — exactly what Python exceptions do implicitly, but explicit and type-checked.

Common Result Methods

let r: Result<i64, _> = "42".parse();

// unwrap_or — provide a default on error
let n = r.unwrap_or(0);

// map — transform the Ok value
let doubled = r.map(|n| n * 2);

// map_err — transform the Err value (e.g. to convert error types)
let r2 = r.map_err(|e| format!("parse failed: {e}"));

// is_ok / is_err
if r.is_ok() { println!("success"); }

// unwrap / expect — panic on Err (use only when Err is truly impossible)
let n = r.expect("hard-coded string must parse");

logferry's Approach to Errors

logferry uses Result in two distinct ways, depending on the context:

Batch ingestion — count and continue

Inside ingest_chunk, a bad line should not abort a 200,000-line batch. So errors are not propagated with ? — they are counted and sampled:

match serde_json::from_str(line) {
    Ok(r) => r,
    Err(e) => {
        // count it, keep going
        stats.parse_errors += 1;
        continue;
    }
};

Single-line validation — fail loudly

validate_line does use ? because a single bad line should raise a Python ValueError:

fn validate_line(line: &str) -> PyResult<bool> {
    let record: LogRecord = serde_json::from_str(line)
        .map_err(|e| PyValueError::new_err(format!("invalid JSON: {e}")))?;
    NonEmptyMessage.validate(&record)?;   // IngestError auto-converts to PyErr
    Ok(true)
}

The auto-conversion from IngestError to PyErr is one impl block:

impl From<IngestError> for PyErr {
    fn from(err: IngestError) -> PyErr {
        PyValueError::new_err(err.to_string())
    }
}

Once that exists, every ? on an IngestError inside a PyResult-returning function becomes a Python ValueError automatically. See Error Handling in PyO3 for the full pattern.


See Also