Skip to content

Chapter 08

Error Handling Done Right

Stop scattering .unwrap() everywhere. Rust's error handling is one of its best features - once you learn the patterns, errors become just another data flow.

Result and Option in Depth

We introduced Option and Result in Chapter 4. Now let's go deeper. These two types are the foundation of everything in this chapter.

Option<T> represents a value that might not exist. Result<T, E> represents an operation that might fail. They're both enums, and they share many of the same methods:

// Option<T> - Some(value) or None
let port: Option<u16> = Some(8080);
let missing: Option<u16> = None;

// Result<T, E> - Ok(value) or Err(error)
let parsed: Result<u16, std::num::ParseIntError> = "8080".parse();
let failed: Result<u16, std::num::ParseIntError> = "abc".parse();

Combinators - Transforming Without Unwrapping

Instead of match-ing every time, both types have methods that let you transform the inner value while keeping the wrapper:

// map - transform the success value
let port: Option<u16> = Some(8080);
let addr: Option<String> = port.map(|p| format!("127.0.0.1:{}", p));
// Some("127.0.0.1:8080")

// and_then (flatMap) - chain operations that might also fail
fn parse_port(s: &str) -> Option<u16> {
    s.parse().ok()  // Result → Option via .ok()
}

let port = Some("8080")
    .and_then(parse_port);  // Some(8080)

let port = Some("abc")
    .and_then(parse_port);  // None - parse failed

// unwrap_or / unwrap_or_else - provide defaults
let port = None::<u16>.unwrap_or(8080);  // 8080
let port = None::<u16>.unwrap_or_else(|| {
    println!("No port specified, using default");
    8080
});

// ok_or - convert Option to Result
let port: Option<u16> = None;
let result: Result<u16, String> = port.ok_or("no port provided".to_string());
// Err("no port provided")
Tip
Think of map as "if there's a value, transform it." Think of and_then as "if there's a value, try this next thing which might also fail." Together they let you chain operations without nesting match blocks.

When to Use Which

MethodUse whenOn failure
.unwrap()You're certain it can't fail, or it's a prototypePanics (crashes)
.expect("msg")Same as unwrap, but with a clear error messagePanics with message
.unwrap_or(val)You have a sensible defaultReturns default
.map(f)Transform inner value, keep the wrapperStays None/Err
.and_then(f)Chain another fallible operationStays None/Err
?Propagate error to callerReturns early

The ? Operator for Propagation

The ? operator is the most important error handling tool in Rust. It replaces the verbose match-and-return pattern with a single character:

// Without ? - verbose and nested
fn read_request_v1(stream: &mut TcpStream) -> Result<String, std::io::Error> {
    let mut buffer = [0u8; 4096];
    let bytes_read = match stream.read(&mut buffer) {
        Ok(n) => n,
        Err(e) => return Err(e),
    };
    match String::from_utf8(buffer[..bytes_read].to_vec()) {
        Ok(s) => Ok(s),
        Err(e) => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            e,
        )),
    }
}

// With ? - clean and flat
fn read_request_v2(stream: &mut TcpStream) -> Result<String, std::io::Error> {
    let mut buffer = [0u8; 4096];
    let bytes_read = stream.read(&mut buffer)?;
    let text = String::from_utf8(buffer[..bytes_read].to_vec())
        .map_err(|e| std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            e,
        ))?;
    Ok(text)
}

When ? is applied to a Result:

  • If it's Ok(v), the expression evaluates to v and execution continues.
  • If it's Err(e), the function immediately returns Err(e).

The same works for Option - None causes an early return of None.

Warning
? can only be used in functions that return Result or Option. You can't use it in main() unless you change its signature to fn main() -> Result<(), E>.

The problem in the example above is that stream.read returns io::Error and String::from_utf8 returns FromUtf8Error - different types. The ? operator can convert between them automatically if the target error type implements From. That's where custom errors come in.

Creating Custom Error Types

A custom error type unifies all the different errors your program can produce. For our webserver, things that can go wrong include: I/O errors, malformed requests, missing headers, and invalid data. Let's model that:

use std::fmt;
use std::io;

#[derive(Debug)]
enum ServerError {
    /// Network or file I/O failure
    Io(io::Error),

    /// The request couldn't be parsed
    ParseError(String),

    /// A required piece of data was missing
    NotFound(String),
}

// Display - for user-facing error messages
impl fmt::Display for ServerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ServerError::Io(e) => write!(f, "I/O error: {}", e),
            ServerError::ParseError(msg) => write!(f, "Parse error: {}", msg),
            ServerError::NotFound(what) => write!(f, "Not found: {}", what),
        }
    }
}

Each variant wraps a different kind of error. The enum gives us a single type that ? can return from anywhere in our code.

From Conversions for Error Chaining

The magic that makes ? work across error types is the From trait. When ? encounters an error, it calls From::from() to convert it into the function's return error type. We implement From for each error type we want to convert from:

// Convert io::Error → ServerError automatically
impl From<io::Error> for ServerError {
    fn from(e: io::Error) -> Self {
        ServerError::Io(e)
    }
}

// Convert String → ServerError (for parse errors)
impl From<String> for ServerError {
    fn from(msg: String) -> Self {
        ServerError::ParseError(msg)
    }
}

// Now ? works seamlessly:
fn read_request(stream: &mut TcpStream) -> Result<Request, ServerError> {
    let mut buffer = [0u8; 4096];
    let n = stream.read(&mut buffer)?;  // io::Error → ServerError::Io via From

    let raw = String::from_utf8_lossy(&buffer[..n]);
    let request = Request::parse(&raw)
        .ok_or_else(|| ServerError::ParseError(
            "malformed HTTP request".to_string()
        ))?;

    Ok(request)
}

Let's trace the error flow through ?:

  1. stream.read(&mut buffer)? - if read fails, the io::Error is converted to ServerError::Io(e) via our From implementation, and the function returns early.
  2. .ok_or_else(|| ...)? - converts Option<Request> to Result<Request, ServerError>. If parsing returned None, we create a ServerError::ParseError and return early.
  3. If everything succeeds, we return Ok(request).
Tip
The pattern is always the same: define an error enum, implement From for each source error type, then use ? everywhere. Each function's body reads like the happy path - error handling is pushed to the type system.

The ok_or and ok_or_else Pattern

You'll often need to convert Option to Result so you can use ?. That's what ok_or and ok_or_else are for:

// Option → Result
fn get_content_length(request: &Request) -> Result<usize, ServerError> {
    let header_value = request.headers
        .get("content-length")
        .ok_or_else(|| ServerError::NotFound(
            "Content-Length header".to_string()
        ))?;

    let length: usize = header_value
        .parse()
        .map_err(|_| ServerError::ParseError(
            format!("invalid Content-Length: {}", header_value)
        ))?;

    Ok(length)
}

.ok_or_else() takes a closure that produces the error (lazy - only called when None). .map_err() transforms the error type of a Result - useful when the source error doesn't have a From implementation.

Graceful Error Responses

Now the important part: turning these errors into proper HTTP responses. A webserver should never crash because of bad input - it should send back an appropriate status code. Let's add a method that converts our error into an HTTP response:

impl ServerError {
    /// Convert this error into an HTTP response.
    fn to_response(&self) -> Response {
        let (status, reason, body) = match self {
            ServerError::Io(e) => (
                500,
                "Internal Server Error",
                format!("<h1>500 - Internal Server Error</h1><p>{}</p>", e),
            ),
            ServerError::ParseError(msg) => (
                400,
                "Bad Request",
                format!("<h1>400 - Bad Request</h1><p>{}</p>", msg),
            ),
            ServerError::NotFound(what) => (
                404,
                "Not Found",
                format!("<h1>404 - Not Found</h1><p>{}</p>", what),
            ),
        };

        let mut resp = Response::new(status, reason, &body);
        resp.add_header("Content-Type", "text/html; charset=utf-8");
        resp
    }
}

Now the connection handler becomes clean and robust:

fn handle_connection(mut stream: TcpStream, router: &Router) {
    let response = match process_request(&mut stream, router) {
        Ok(resp) => resp,
        Err(e) => {
            eprintln!("Error: {}", e);
            e.to_response()
        }
    };

    // Writing can fail too - but if it does, there's nothing
    // useful we can send back, so just log it
    if let Err(e) = stream.write_all(&response.to_bytes()) {
        eprintln!("Write error: {}", e);
    }
    let _ = stream.flush();
}

fn process_request(
    stream: &mut TcpStream,
    router: &Router,
) -> Result<Response, ServerError> {
    let mut buffer = [0u8; 4096];
    let n = stream.read(&mut buffer)?;

    if n == 0 {
        return Err(ServerError::ParseError("empty request".to_string()));
    }

    let raw = String::from_utf8_lossy(&buffer[..n]);
    let request = Request::parse(&raw)
        .ok_or_else(|| ServerError::ParseError(
            "malformed HTTP request".to_string()
        ))?;

    println!("{} {}", request.method, request.path);
    Ok(router.route(&request))
}

The structure is now clearly separated:

  • process_request is the happy path - it reads, parses, routes, and returns a Result. Every step that can fail uses ?.
  • handle_connection is the boundary - it catches errors and converts them to HTTP responses. It never panics.
  • Errors are data - they flow through Result just like successful values, and they're converted to responses at the boundary.
Note
This pattern - happy path in one function, error-to-response conversion at the boundary - is exactly what production web frameworks like Actix and Axum use. Handlers return Result, and the framework converts errors to responses.

Putting It Together

Here's the complete server with proper error handling. Every .unwrap() from previous chapters is gone (except the initial TcpListener::bind - if the server can't bind, crashing is correct):

src/main.rs
use std::collections::HashMap;
use std::fmt;
use std::io::{self, Read, Write};
use std::net::{TcpListener, TcpStream};

// --- Error type ---

#[derive(Debug)]
enum ServerError {
    Io(io::Error),
    ParseError(String),
    NotFound(String),
}

impl fmt::Display for ServerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ServerError::Io(e) => write!(f, "I/O error: {}", e),
            ServerError::ParseError(msg) => write!(f, "Parse error: {}", msg),
            ServerError::NotFound(what) => write!(f, "Not found: {}", what),
        }
    }
}

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

impl ServerError {
    fn to_response(&self) -> Response {
        let (status, reason, body) = match self {
            ServerError::Io(e) => (500, "Internal Server Error",
                format!("<h1>500</h1><p>Server error: {}</p>", e)),
            ServerError::ParseError(msg) => (400, "Bad Request",
                format!("<h1>400</h1><p>Bad request: {}</p>", msg)),
            ServerError::NotFound(what) => (404, "Not Found",
                format!("<h1>404</h1><p>{} not found</p>", what)),
        };
        let mut resp = Response::new(status, reason, &body);
        resp.add_header("Content-Type", "text/html; charset=utf-8");
        resp
    }
}

// --- HTTP types ---

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Method { Get, Post }

impl Method {
    fn parse(s: &str) -> Result<Method, ServerError> {
        match s {
            "GET" => Ok(Method::Get),
            "POST" => Ok(Method::Post),
            other => Err(ServerError::ParseError(
                format!("unsupported method: {}", other)
            )),
        }
    }
}

impl fmt::Display for Method {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Method::Get => write!(f, "GET"),
            Method::Post => write!(f, "POST"),
        }
    }
}

struct Request {
    method: Method,
    path: String,
    headers: HashMap<String, String>,
}

impl Request {
    fn parse(raw: &str) -> Result<Request, ServerError> {
        let mut lines = raw.split("\r\n");

        let request_line = lines.next()
            .ok_or_else(|| ServerError::ParseError(
                "empty request".to_string()
            ))?;

        let mut parts = request_line.split_whitespace();

        let method_str = parts.next()
            .ok_or_else(|| ServerError::ParseError(
                "missing method".to_string()
            ))?;
        let method = Method::parse(method_str)?;

        let path = parts.next()
            .ok_or_else(|| ServerError::ParseError(
                "missing path".to_string()
            ))?
            .to_string();

        let headers: HashMap<String, String> = lines
            .take_while(|line| !line.is_empty())
            .filter_map(|line| {
                let (key, value) = line.split_once(':')?;
                Some((key.trim().to_lowercase(), value.trim().to_string()))
            })
            .collect();

        Ok(Request { method, path, headers })
    }
}

struct Response {
    status: u16,
    reason: &'static str,
    headers: Vec<(String, String)>,
    body: String,
}

impl Response {
    fn new(status: u16, reason: &'static str, body: &str) -> Self {
        Response {
            status, reason,
            headers: vec![],
            body: body.to_string(),
        }
    }

    fn html(body: &str) -> Self {
        let mut resp = Self::new(200, "OK", body);
        resp.add_header("Content-Type", "text/html; charset=utf-8");
        resp
    }

    fn add_header(&mut self, name: &str, value: &str) {
        self.headers.push((name.to_string(), value.to_string()));
    }

    fn to_bytes(&self) -> Vec<u8> {
        let mut out = format!(
            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
            self.status, self.reason, self.body.len()
        );
        for (k, v) in &self.headers {
            out.push_str(&format!("{}: {}\r\n", k, v));
        }
        out.push_str("\r\n");
        out.push_str(&self.body);
        out.into_bytes()
    }
}

// --- Router ---

type Handler = fn(&Request) -> Result<Response, ServerError>;

struct Router {
    routes: HashMap<(Method, String), Handler>,
}

impl Router {
    fn new() -> Self { Router { routes: HashMap::new() } }

    fn add(&mut self, method: Method, path: &str, handler: Handler) {
        self.routes.insert((method, path.to_string()), handler);
    }

    fn route(&self, request: &Request) -> Result<Response, ServerError> {
        let key = (request.method.clone(), request.path.clone());
        match self.routes.get(&key) {
            Some(handler) => handler(request),
            None => Err(ServerError::NotFound(
                format!("{} {}", request.method, request.path)
            )),
        }
    }
}

// --- Handlers ---

fn home(_req: &Request) -> Result<Response, ServerError> {
    Ok(Response::html("<h1>Welcome!</h1><p><a href=\"/about\">About</a></p>"))
}

fn about(_req: &Request) -> Result<Response, ServerError> {
    Ok(Response::html("<h1>About</h1><p>A Rust webserver with proper error handling.</p>"))
}

// --- Server ---

fn process_request(
    stream: &mut TcpStream,
    router: &Router,
) -> Result<Response, ServerError> {
    let mut buffer = [0u8; 4096];
    let n = stream.read(&mut buffer)?;

    if n == 0 {
        return Err(ServerError::ParseError("empty request".to_string()));
    }

    let raw = String::from_utf8_lossy(&buffer[..n]);
    let request = Request::parse(&raw)?;
    println!("{} {} → ", request.method, request.path);
    router.route(&request)
}

fn handle_connection(mut stream: TcpStream, router: &Router) {
    let response = match process_request(&mut stream, &router) {
        Ok(resp) => {
            println!("{}", resp.status);
            resp
        }
        Err(e) => {
            eprintln!("Error: {}", e);
            e.to_response()
        }
    };

    if let Err(e) = stream.write_all(&response.to_bytes()) {
        eprintln!("Write failed: {}", e);
    }
    let _ = stream.flush();
}

fn main() {
    let mut router = Router::new();
    router.add(Method::Get, "/", home);
    router.add(Method::Get, "/about", about);

    let addr = "127.0.0.1:8080";
    let listener = TcpListener::bind(addr).expect("failed to bind");
    println!("Listening on http://{}", addr);

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => handle_connection(stream, &router),
            Err(e) => eprintln!("Accept failed: {}", e),
        }
    }
}

Test the error handling:

# 200 - success
curl -i http://127.0.0.1:8080/

# 404 - unknown route (via ServerError::NotFound)
curl -i http://127.0.0.1:8080/nope

# 400 - unsupported method (via ServerError::ParseError)
curl -i -X DELETE http://127.0.0.1:8080/

# 400 - malformed request
echo "GARBAGE" | nc localhost 8080

Every error produces a proper HTTP response with the right status code. The server never panics, never crashes, and logs every error for debugging.

Note
Notice how the handler type changed: type Handler = fn(&Request) -> Result<Response, ServerError>. Handlers can now fail too - useful for handlers that read files, query databases, or parse JSON bodies. The error becomes a proper HTTP response automatically.

Exercise

  1. Add a Timeout variant to ServerError that produces a 408 Request Timeout response. Where in the code would you return this error?
  2. Change Request::parse to validate that the path starts with /. If it doesn't, return a ParseError. Test with echo 'GET nopath HTTP/1.1\r\n\r\n' | nc localhost 8080.
  3. Add a POST /echo handler that reads the request body. If the Content-Length header is missing, return a ServerError::ParseError. If the value isn't a valid number, return a different error message. Use ? throughout.
  4. Implement the std::error::Error trait for ServerError. This is a marker trait that requires Debug + Display (which we already have). It unlocks compatibility with error-handling crates like anyhow and thiserror.
  5. Replace .expect("failed to bind") in main with proper error handling: change main to return Result<(), ServerError> and use ?.

What's Next

Error handling is no longer an afterthought - it's built into the type signatures. Every function declares what can go wrong, the ? operator propagates errors cleanly, and the connection handler converts them to appropriate HTTP responses.

Our main.rs is getting long though - types, parsing, routing, handlers, and the server loop are all in one file. In the next chapter, we'll learn modules, crates, and project organization - how to split this into a clean multi-file structure.