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")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
| Method | Use when | On failure |
|---|---|---|
| .unwrap() | You're certain it can't fail, or it's a prototype | Panics (crashes) |
| .expect("msg") | Same as unwrap, but with a clear error message | Panics with message |
| .unwrap_or(val) | You have a sensible default | Returns default |
| .map(f) | Transform inner value, keep the wrapper | Stays None/Err |
| .and_then(f) | Chain another fallible operation | Stays None/Err |
| ? | Propagate error to caller | Returns 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 tovand execution continues. - If it's
Err(e), the function immediately returnsErr(e).
The same works for Option - None causes an early return of None.
? 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 ?:
stream.read(&mut buffer)?- if read fails, theio::Erroris converted toServerError::Io(e)via ourFromimplementation, and the function returns early..ok_or_else(|| ...)?- convertsOption<Request>toResult<Request, ServerError>. If parsing returnedNone, we create aServerError::ParseErrorand return early.- If everything succeeds, we return
Ok(request).
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_requestis the happy path - it reads, parses, routes, and returns aResult. Every step that can fail uses?.handle_connectionis the boundary - it catches errors and converts them to HTTP responses. It never panics.- Errors are data - they flow through
Resultjust like successful values, and they're converted to responses at the boundary.
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):
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 8080Every error produces a proper HTTP response with the right status code. The server never panics, never crashes, and logs every error for debugging.
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
- Add a
Timeoutvariant toServerErrorthat produces a408 Request Timeoutresponse. Where in the code would you return this error? - Change
Request::parseto validate that the path starts with/. If it doesn't, return aParseError. Test withecho 'GET nopath HTTP/1.1\r\n\r\n' | nc localhost 8080. - Add a
POST /echohandler that reads the request body. If theContent-Lengthheader is missing, return aServerError::ParseError. If the value isn't a valid number, return a different error message. Use?throughout. - Implement the
std::error::Errortrait forServerError. This is a marker trait that requiresDebug + Display(which we already have). It unlocks compatibility with error-handling crates likeanyhowandthiserror. - Replace
.expect("failed to bind")inmainwith proper error handling: changemainto returnResult<(), 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.