Debug School

rakesh kumar
rakesh kumar

Posted on

Error handling in Rust

panic (Rust)
abort & unwind
Option & unwrap
Result
Multiple error types
Error Handling: Rust vs Java (Complete Comparison Table)
real-world Rust error-handling patterns

Error handling is the process of handling the possibility of failure. For example, failing to read a file and then continuing to use that bad input would clearly be problematic. Noticing and explicitly managing those errors saves the rest of the program from various pitfalls.

There are various ways to deal with errors in Rust, which are described in the following subchapters. They all have more or less subtle differences and different use cases. As a rule of thumb:

panic (Rust)

Theory

panic! means: “something went seriously wrong, stop this thread.”
Use it for bugs / impossible states, not for normal user input errors.

Example: “index out of bounds”, “this should never happen”, “broken invariant”.

18.2

abort & unwind

Theory

When panic happens, Rust can handle it in two ways:

unwind (default): Rust unwinds the stack, runs Drop (cleanup), then stops (or can be caught in limited cases).

abort: Rust immediately terminates the process (faster binary, no unwinding, no cleanup guarantees).

You choose using Cargo.toml (compile-time):

[profile.release]
panic = "abort"
Enter fullscreen mode Exit fullscreen mode

18.3

Option & unwrap

Theory

Option is used when a value may be missing:

Some(value)

None

unwrap():
Enter fullscreen mode Exit fullscreen mode

returns the value if Some

panics if None

Better alternatives: match, unwrap_or, expect, ? (when converting to Result).

18.4

Result

Theory

Result is used when an operation can fail:

Ok(value)

Err(error)
Enter fullscreen mode Exit fullscreen mode

Rust encourages explicit error handling instead of exceptions.

18.5

Multiple error types

Theory

Sometimes a function can fail in different ways. Common approaches:

Create your own enum error type (best for apps)

Use Box (simple, flexible)

Use libraries like anyhow/thiserror (not needed for basic learning)

18.6

Iterating over Results

Theory

If you have Vec>, you can:

stop on first error: collect::, E>>()

keep only successes: filter_map(Result::ok)

handle each item with match

Full Rust code (covers 18.1 → 18.6)

use std::error::Error;
use std::fmt;

// -------- 18.5 Multiple error types: custom error enum --------
#[derive(Debug)]
enum AppError {
    EmptyInput,
    NotANumber(String),
    TooSmall(i32),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::EmptyInput => write!(f, "input is empty"),
            AppError::NotANumber(s) => write!(f, "not a valid integer: '{}'", s),
            AppError::TooSmall(n) => write!(f, "number is too small: {}", n),
        }
    }
}

impl Error for AppError {}

// 18.4 Result usage: parse + validate
fn parse_and_validate(input: &str) -> Result<i32, AppError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(AppError::EmptyInput);
    }

    let n: i32 = trimmed
        .parse::<i32>()
        .map_err(|_| AppError::NotANumber(trimmed.to_string()))?;

    if n < 10 {
        return Err(AppError::TooSmall(n));
    }

    Ok(n)
}

// 18.3 Option example
fn find_user(id: i32) -> Option<&'static str> {
    match id {
        1 => Some("Ashwani"),
        2 => Some("Ravi"),
        _ => None,
    }
}

// 18.2 unwind: catch panic (works only for unwinding panics)
fn demo_catch_unwind() {
    println!("--- 18.2 catch_unwind demo (unwind only) ---");
    let result = std::panic::catch_unwind(|| {
        println!("Inside closure: about to panic...");
        panic!("boom!");
    });

    match result {
        Ok(_) => println!("No panic happened"),
        Err(_) => println!("Panic was caught (program continues)"),
    }
}

// 18.6 Iterating over Results
fn demo_iterating_results() {
    println!("--- 18.6 Iterating over Results ---");
    let inputs = vec!["12", " 9 ", "abc", "30"];

    let parsed: Vec<Result<i32, AppError>> = inputs
        .iter()
        .map(|s| parse_and_validate(s))
        .collect();

    println!("Per-item handling (match):");
    for (i, r) in parsed.iter().enumerate() {
        match r {
            Ok(v) => println!("  item {} => Ok({})", i, v),
            Err(e) => println!("  item {} => Err({})", i, e),
        }
    }

    // stop on first error:
    let all_ok: Result<Vec<i32>, AppError> = inputs.iter().map(|s| parse_and_validate(s)).collect();
    println!("collect into Result<Vec<_>> (fails on first Err): {:?}", all_ok);

    // keep only successes:
    let ok_only: Vec<i32> = inputs
        .iter()
        .filter_map(|s| parse_and_validate(s).ok())
        .collect();
    println!("only Ok values: {:?}", ok_only);
}

fn main() {
    println!("--- 18.3 Option & unwrap ---");
    let u1 = find_user(1);
    println!("find_user(1) => {:?}", u1);
    println!("unwrap Some => {}", u1.unwrap());

    let u9 = find_user(9);
    println!("find_user(9) => {:?}", u9);
    println!("unwrap_or => {}", u9.unwrap_or("Guest"));

    // Don't actually crash the demo; show unwrap panic safely via catch_unwind
    let unwrap_panic = std::panic::catch_unwind(|| {
        let none_user = find_user(999);
        none_user.unwrap(); // would panic
    });
    println!("unwrap on None panics? {}", unwrap_panic.is_err());

    println!("\n--- 18.4 Result ---");
    for s in ["15", "8", "xyz", "50"] {
        match parse_and_validate(s) {
            Ok(v) => println!("input '{}' => Ok({})", s, v),
            Err(e) => println!("input '{}' => Err({})", s, e),
        }
    }

    println!();
    demo_catch_unwind();

    println!("\n--- 18.1 panic (shown safely) ---");
    let p = std::panic::catch_unwind(|| {
        println!("About to panic now...");
        panic!("this is a deliberate panic example");
    });
    println!("panic happened? {}", p.is_err());

    println!("\n--- 18.5 Multiple error types (already shown via AppError enum) ---");
    let bad = parse_and_validate("  ");
    println!("parse_and_validate('  ') => {:?}", bad);

    println!();
    demo_iterating_results();

    println!("\nNOTE: 18.2 abort vs unwind is chosen at compile-time (Cargo.toml).");
}
Enter fullscreen mode Exit fullscreen mode

Output (Rust)

--- 18.3 Option & unwrap ---
find_user(1) => Some("Ashwani")
unwrap Some => Ashwani
find_user(9) => None
unwrap_or => Guest
unwrap on None panics? true

--- 18.4 Result ---
input '15' => Ok(15)
input '8' => Err(number is too small: 8)
input 'xyz' => Err(not a valid integer: 'xyz')
input '50' => Ok(50)

--- 18.2 catch_unwind demo (unwind only) ---
Inside closure: about to panic...
Panic was caught (program continues)

--- 18.1 panic (shown safely) ---
About to panic now...
panic happened? true

--- 18.5 Multiple error types (already shown via AppError enum) ---
parse_and_validate('  ') => Err(EmptyInput)

--- 18.6 Iterating over Results ---
Per-item handling (match):
  item 0 => Ok(12)
  item 1 => Err(number is too small: 9)
  item 2 => Err(not a valid integer: 'abc')
  item 3 => Ok(30)
collect into Result<Vec<_>> (fails on first Err): Err(TooSmall(9))
only Ok values: [12, 30]

Enter fullscreen mode Exit fullscreen mode

NOTE: 18.2 abort vs unwind is chosen at compile-time (Cargo.toml).

Java comparison (exceptions + Optional)

Java uses:

exceptions for errors (try/catch)

Optional for maybe-values

no direct “unwind vs abort” switch like Rust; program ends if exception is uncaught

Full Java code + output

import java.util.*;
import java.util.stream.Collectors;

public class Main {

    // 18.5 Multiple error types: custom exception hierarchy
    static class AppException extends Exception {
        AppException(String msg) { super(msg); }
    }
    static class EmptyInput extends AppException {
        EmptyInput() { super("input is empty"); }
    }
    static class NotANumber extends AppException {
        NotANumber(String s) { super("not a valid integer: '" + s + "'"); }
    }
    static class TooSmall extends AppException {
        TooSmall(int n) { super("number is too small: " + n); }
    }

    // 18.4 Result equivalent: in Java we throw exceptions
    static int parseAndValidate(String input) throws AppException {
        String trimmed = input.trim();
        if (trimmed.isEmpty()) throw new EmptyInput();

        int n;
        try {
            n = Integer.parseInt(trimmed);
        } catch (NumberFormatException e) {
            throw new NotANumber(trimmed);
        }

        if (n < 10) throw new TooSmall(n);
        return n;
    }

    // 18.3 Option equivalent: Optional
    static Optional<String> findUser(int id) {
        if (id == 1) return Optional.of("Ashwani");
        if (id == 2) return Optional.of("Ravi");
        return Optional.empty();
    }

    public static void main(String[] args) {
        System.out.println("--- 18.3 Optional & unwrap-like ---");
        Optional<String> u1 = findUser(1);
        System.out.println("findUser(1) => " + u1);
        System.out.println("get() => " + u1.get());

        Optional<String> u9 = findUser(9);
        System.out.println("findUser(9) => " + u9);
        System.out.println("orElse => " + u9.orElse("Guest"));

        try {
            u9.get(); // throws NoSuchElementException
        } catch (NoSuchElementException e) {
            System.out.println("get() on empty throws? true");
        }

        System.out.println("\n--- 18.4 Result (exceptions) ---");
        for (String s : new String[]{"15", "8", "xyz", "50"}) {
            try {
                int v = parseAndValidate(s);
                System.out.println("input '" + s + "' => Ok(" + v + ")");
            } catch (AppException e) {
                System.out.println("input '" + s + "' => Err(" + e.getMessage() + ")");
            }
        }

        System.out.println("\n--- 18.1 panic equivalent: RuntimeException ---");
        try {
            System.out.println("About to throw RuntimeException...");
            throw new RuntimeException("this is a deliberate crash example");
        } catch (RuntimeException e) {
            System.out.println("crash happened? true");
        }

        System.out.println("\n--- 18.6 Iterating over results ---");
        List<String> inputs = Arrays.asList("12", " 9 ", "abc", "30");

        System.out.println("Per-item handling:");
        List<Integer> okOnly = new ArrayList<>();
        for (int i = 0; i < inputs.size(); i++) {
            try {
                int v = parseAndValidate(inputs.get(i));
                System.out.println("  item " + i + " => Ok(" + v + ")");
                okOnly.add(v);
            } catch (AppException e) {
                System.out.println("  item " + i + " => Err(" + e.getMessage() + ")");
            }
        }
        System.out.println("only Ok values: " + okOnly);

        System.out.println("\nNOTE: Java has no compile-time abort/unwind option like Rust.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (Java)

--- 18.3 Optional & unwrap-like ---
findUser(1) => Optional[Ashwani]
get() => Ashwani
findUser(9) => Optional.empty
orElse => Guest
get() on empty throws? true

--- 18.4 Result (exceptions) ---
input '15' => Ok(15)
input '8' => Err(number is too small: 8)
input 'xyz' => Err(not a valid integer: 'xyz')
input '50' => Ok(50)

--- 18.1 panic equivalent: RuntimeException ---
About to throw RuntimeException...
crash happened? true

--- 18.6 Iterating over results ---
Per-item handling:
  item 0 => Ok(12)
  item 1 => Err(number is too small: 9)
  item 2 => Err(not a valid integer: 'abc')
  item 3 => Ok(30)
only Ok values: [12, 30]
Enter fullscreen mode Exit fullscreen mode

NOTE: Java has no compile-time abort/unwind option like Rust.

Error Handling: Rust vs Java (Complete Comparison Table)

Real-world Rust error-handling patterns


use std::collections::HashMap;
use std::error::Error;
use std::fmt;

// -----------------------------
// Low-level errors (Repo layer)
// -----------------------------
#[derive(Debug)]
enum RepoError {
    MissingKey { key: String },
}

impl fmt::Display for RepoError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            RepoError::MissingKey { key } => write!(f, "missing config key: {}", key),
        }
    }
}
impl Error for RepoError {}

// -----------------------------
// Service layer errors (wrap RepoError)
// -----------------------------
#[derive(Debug)]
enum ServiceError {
    ConfigLoad { source: RepoError },
    InvalidPort { value: String },
    UserNotFound { user_id: i32 },
}

impl fmt::Display for ServiceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ServiceError::ConfigLoad { source } => write!(f, "failed to load config: {}", source),
            ServiceError::InvalidPort { value } => write!(f, "invalid port value: '{}'", value),
            ServiceError::UserNotFound { user_id } => write!(f, "user not found: id={}", user_id),
        }
    }
}

impl Error for ServiceError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ServiceError::ConfigLoad { source } => Some(source),
            _ => None,
        }
    }
}

// Convert RepoError -> ServiceError automatically so `?` works across layers
impl From<RepoError> for ServiceError {
    fn from(e: RepoError) -> Self {
        ServiceError::ConfigLoad { source: e }
    }
}

// -----------------------------
// "Repository" (pretend config store / DB)
// -----------------------------
fn repo_get(config: &HashMap<String, String>, key: &str) -> Result<String, RepoError> {
    config.get(key).cloned().ok_or_else(|| RepoError::MissingKey {
        key: key.to_string(),
    })
}

// -----------------------------
// Service layer: uses `?` + adds validation
// -----------------------------
fn service_load_port(config: &HashMap<String, String>) -> Result<u16, ServiceError> {
    // `?` returns ServiceError (via From<RepoError>)
    let port_str = repo_get(config, "PORT")?;

    let port_num: u16 = port_str
        .parse::<u16>()
        .map_err(|_| ServiceError::InvalidPort { value: port_str.clone() })?;

    Ok(port_num)
}

fn service_get_username(users: &HashMap<i32, String>, user_id: i32) -> Result<String, ServiceError> {
    users
        .get(&user_id)
        .cloned()
        .ok_or(ServiceError::UserNotFound { user_id })
}

// -----------------------------
// "API/CLI layer": friendly printing
// -----------------------------
fn run_app(config: HashMap<String, String>, users: HashMap<i32, String>, user_id: i32) -> Result<(), ServiceError> {
    let port = service_load_port(&config)?;                 // `?` propagate with context
    let username = service_get_username(&users, user_id)?;  // `?` propagate

    println!("Server will start on port: {}", port);
    println!("Hello, {}!", username);
    Ok(())
}

fn main() {
    // Case A: OK
    println!("--- Case A: Success ---");
    let mut config_ok = HashMap::new();
    config_ok.insert("PORT".to_string(), "8080".to_string());

    let mut users = HashMap::new();
    users.insert(1, "Ashwani".to_string());

    match run_app(config_ok, users.clone(), 1) {
        Ok(_) => println!("App finished successfully\n"),
        Err(e) => print_error_chain(e),
    }

    // Case B: Missing config (layered error)
    println!("--- Case B: Missing PORT (layered error) ---");
    let config_missing = HashMap::new();

    match run_app(config_missing, users.clone(), 1) {
        Ok(_) => println!("App finished successfully\n"),
        Err(e) => print_error_chain(e),
    }

    // Case C: Invalid port (validation error)
    println!("\n--- Case C: Invalid PORT ---");
    let mut config_bad_port = HashMap::new();
    config_bad_port.insert("PORT".to_string(), "eightythousand".to_string());

    match run_app(config_bad_port, users.clone(), 1) {
        Ok(_) => println!("App finished successfully\n"),
        Err(e) => print_error_chain(e),
    }

    // Case D: User not found
    println!("\n--- Case D: User not found ---");
    let mut config_ok2 = HashMap::new();
    config_ok2.insert("PORT".to_string(), "3000".to_string());

    match run_app(config_ok2, users, 999) {
        Ok(_) => println!("App finished successfully\n"),
        Err(e) => print_error_chain(e),
    }
}

// Print layered errors (top error + source chain)
fn print_error_chain(mut e: ServiceError) {
    println!("ERROR: {}", e);
    let mut current: &dyn Error = &e;
    while let Some(src) = current.source() {
        println!("  caused by: {}", src);
        current = src;
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (Rust)

--- Case A: Success ---
Server will start on port: 8080
Hello, Ashwani!
App finished successfully

--- Case B: Missing PORT (layered error) ---
ERROR: failed to load config: missing config key: PORT
  caused by: missing config key: PORT

--- Case C: Invalid PORT ---
ERROR: invalid port value: 'eightythousand'

--- Case D: User not found ---
ERROR: user not found: id=999

Enter fullscreen mode Exit fullscreen mode

Error Handling

Error.rs
Function defination

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json
};
use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
    pub status: String,
    pub message: String,
}

impl fmt::Display for ErrorResponse {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", serde_json::to_string(&self).unwrap())
    }
}

#[derive(Debug, PartialEq)]
pub enum ErrorMessage {
    EmptyPassword,
    ExceededMaxPasswordLength(usize),
    InvalidHashFormat,
    HashingError,
    InvalidToken,
    ServerError,
    WrongCredentials,
    EmailExist,
    UserNoLongerExist,
    TokenNotProvided,
    PermissionDenied,
    UserNotAuthenticated,
}

impl ToString for ErrorMessage {
    fn to_string(&self) -> String {
        self.to_str().to_owned()
    }
}

impl ErrorMessage {
    fn to_str(&self) -> String {
        match self {
            ErrorMessage::ServerError => "Server Error. Please try again later".to_string(),
            ErrorMessage::WrongCredentials => "Email or password is wrong".to_string(),
            ErrorMessage::EmailExist => "A user with this email already exists".to_string(),
            ErrorMessage::UserNoLongerExist => "User belonging to this token no longer exists".to_string(),
            ErrorMessage::EmptyPassword => "Password cannot be empty".to_string(),
            ErrorMessage::HashingError => "Error while hashing password".to_string(),
            ErrorMessage::InvalidHashFormat => "Invalid password hash format".to_string(),
            ErrorMessage::ExceededMaxPasswordLength(max_length) => format!("Password must not be more than {} characters", max_length),
            ErrorMessage::InvalidToken => "Authentication token is invalid or expired".to_string(),
            ErrorMessage::TokenNotProvided => "You are not logged in, please provide a token".to_string(),
            ErrorMessage::PermissionDenied => "You are not allowed to perform this action".to_string(),
            ErrorMessage::UserNotAuthenticated => "Authentication required. Please log in.".to_string(),
        }
    }
}


#[derive(Debug,Clone)]
pub struct HttpError {
    pub message: String,
    pub status: StatusCode,
}

impl HttpError {
    pub fn new(message: impl Into<String>, status: StatusCode) -> Self {
        HttpError {
            message: message.into(),
            status,
        }
    }

    pub fn server_error(message: impl Into<String>) -> Self {
        HttpError {
            message: message.into(),
            status: StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    pub fn bad_request(message: impl Into<String>) -> Self {
        HttpError {
            message: message.into(),
            status: StatusCode::BAD_REQUEST,
        }
    }

    pub fn unique_constraint_violation(message: impl Into<String>) -> Self {
        HttpError { 
            message: message.into(), 
            status: StatusCode::CONFLICT 
        }
    }

    pub fn unauthorized(message: impl Into<String>) -> Self {
        HttpError {
            message: message.into(),
            status: StatusCode::UNAUTHORIZED,
        }
    }

    pub fn into_http_response(self) -> Response {
        let json_response = Json(ErrorResponse {
            status: "fail".to_string(),
            message: self.message.clone(),
        });

        (self.status, json_response).into_response()
    }
}

impl fmt::Display for HttpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "HttpError: message: {}, status: {}",
            self.message, self.status
        )
    }
}

impl std::error::Error for HttpError {}

impl IntoResponse for HttpError {
    fn into_response(self) -> Response {
        self.into_http_response()
    }
}
Enter fullscreen mode Exit fullscreen mode

How to call function

use axum::{Json};
use serde::Deserialize;
use crate::error::{HttpError, ErrorMessage};

#[derive(Deserialize)]
struct LoginRequest {
    email: String,
    password: String,
}

pub async fn login_handler(
    Json(payload): Json<LoginRequest>,
) -> Result<Json<String>, HttpError> {

    if payload.password.is_empty() {
        return Err(
            HttpError::bad_request(ErrorMessage::EmptyPassword.to_string())
        );
    }

    if payload.password.len() > 20 {
        return Err(
            HttpError::bad_request(
                ErrorMessage::ExceededMaxPasswordLength(20).to_string()
            )
        );
    }

    // success response
    Ok(Json("Login successful".to_string()))
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)