6. Error handling and service boundaries
This chapter is the companion to the async chapter. Once you can read the
waiting points, the next skill is reading the failure paths. In babar, that
usually means understanding three moves:
- propagate an error with
? - inspect an error with
match - translate a low-level error into an application-facing one at the boundary
babar anchor
Keep these pages open while reading:
Together they show the whole path from connection setup to HTTP response.
Start with the function signature
Rust makes failure visible in the type signature. In the babar docs you will
see shapes such as:
async fn main() -> babar::Result<()> { /* ... */ }
async fn initialize(pool: &Pool) -> babar::Result<()> { /* ... */ }
fn db_http(err: babar::Error) -> (StatusCode, String) { /* ... */ }
Read them literally:
babar::Result<()>means “this function may fail withbabar::Error”Result<T, (StatusCode, String)>means “this handler either returns success data or an HTTP-facing error payload”()means “successful completion matters, but there is no extra success value”
The signature already tells you where responsibility for the error currently lives.
What ? means in babar code
In both 1. Connecting and
9. Error handling, the ? operator appears on
async database calls:
#![allow(unused)]
fn main() {
let session = Session::connect(cfg).await?;
session.execute(&create, ()).await?;
let pool = Pool::new(cfg, PoolConfig::new().max_size(8)).await?;
}
? is short for a very specific decision:
- if the call succeeded, keep going with the success value
- if the call failed, return that error from the current function immediately
So when you read await?, split it into two questions:
- what outside work are we waiting for?
- if that work fails, who is responsible next?
That makes ? much less mysterious. It is not swallowing errors. It is
propagating them on purpose.
When to use ? and when to use match
Use ? when the current function is not the place that adds meaning.
Use match when the current function is the place that must classify or
translate the error.
That is why the error-handling chapter includes:
#![allow(unused)]
fn main() {
match session.execute(&insert, (2, "ada".into())).await {
Ok(_) => unreachable!(),
Err(err) => match classify(&err) {
Failure::Duplicate => println!("duplicate name; skipping"),
Failure::ServerOther { code } => println!("server error {code}"),
Failure::IoOrClosed => println!("connection died; retry later"),
Failure::Bug => println!("our bug, not the server's: {err}"),
},
}
}
The code is not just asking “did this fail?”. It is asking “what kind of failure is this, and what should the application do next?”.
babar::Error is an enum, so classification is explicit
9. Error handling stresses one rule: there is no
Error::kind() convenience classifier. You inspect the variant directly.
That is good Rust practice for a learner to notice. It means the library is not hiding the shape of failure from you. A few useful buckets are:
Error::Io(_)andError::Closed { .. }for transport-level troubleError::Auth(_)andError::UnsupportedAuth(_)for authentication failuresError::Server { code, .. }for Postgres server errorsError::Config(_)for setup mistakes caught before I/OError::Codec(_),Error::SchemaMismatch { .. }, andError::ColumnAlignment { .. }for typed-data mismatches
This is one of the biggest Rust differences from exception-heavy code: the error cases are normal values, and pattern matching is the standard way to reason about them.
Why SQLSTATE is the stable boundary
When Postgres rejects a statement, Error::Server { code, message, .. } carries
both a machine-friendly code and a human-friendly message. The docs tell you to
match the SQLSTATE instead of the message text:
#![allow(unused)]
fn main() {
Error::Server { code, .. } if code == "23505" => Failure::Duplicate
}
That is more reliable because:
- SQLSTATE is designed for programmatic handling
- messages are written for humans and may vary in wording
- your service logic usually cares about categories such as duplicate key, foreign-key violation, timeout, or retryable transaction failure
Use the Error catalog when you need the wider table of codes; use the book chapter when you need the mental model.
Service boundaries are where translation happens
The web-service chapter shows the next layer up:
#![allow(unused)]
fn main() {
fn db_http(err: babar::Error) -> (StatusCode, String) {
match err {
babar::Error::Server { code, .. } if code == "23505" => {
(StatusCode::CONFLICT, "already exists".into())
}
babar::Error::Server { code, .. } if code == "23503" => {
(StatusCode::UNPROCESSABLE_ENTITY, "foreign key violation".into())
}
other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
}
}
}
This is an important application-flow boundary:
- below the boundary, code talks in driver/database terms
- at the boundary, code translates that into HTTP or domain terms
- above the boundary, callers should not need to understand
babar::Error
That translation is where application meaning appears. A duplicate key becomes a conflict. A missing foreign-key target becomes an unprocessable request. A pool timeout may become service unavailable.
Separate operational detail from client-facing meaning
11. Building a web service explicitly warns against returning every raw database error straight to the client. That is a good rule for two reasons:
- low-level messages may leak implementation detail
- clients usually need stable application meaning, not driver internals
A practical reading rule:
- logs and diagnostics may include the detailed
babar::Error - client responses should usually expose a smaller, domain-appropriate shape
Pool errors and database errors are related, but not identical
Service code often handles two fallible steps in sequence:
#![allow(unused)]
fn main() {
let conn = state.pool.acquire().await.map_err(pool_http)?;
let rows = conn.query(&select, (id,)).await.map_err(db_http)?;
}
That split is worth noticing:
PoolErroranswers “could the application obtain a usable connection?”babar::Erroranswers “what went wrong while speaking to Postgres?”
Both are database-adjacent, but they are not the same boundary. Good service code usually keeps that distinction clear.
Python comparison (optional)
If you come from Python, Rust error values can feel like exceptions made
explicit. The Rust-first lesson is stricter than that: failure paths are part of
the function type, propagation is visible in ?, and classification is usually
done with match, not by catching a broad exception late.
Checkpoint
Try to answer these from the docs examples:
- When a
babarcall ends withawait?, which function has agreed to handle the error next? - Why is
code == "23505"a better service boundary than checking whether an error message contains the word “duplicate”? - In the Axum example, which failures should stay in logs, and which should be translated into stable HTTP meanings?