Lesson 1 ended with two .unwrap() calls and a promise. Time to pay it.
In the Java original, error handling is spread across the machine: Stripes
catches what ActionBeans throw, Spring wraps SQL failures in its own runtime
hierarchy, and somewhere at the bottom of web.xml sits an error page for
whatever leaks through. It works, but nobody can point at the one place where
“what can go wrong” is written down.
In this port, that place is a single file. By the end of this lesson the app
has its module skeleton, one error type that every handler returns, and a
styled error page that unknown URLs actually reach. The code is tagged
lesson-02.
The skeleton, such as it is
src/
├── main.rs wiring: build the router, bind, serve
└── web/
├── mod.rs routes
├── error.rs the error type and how it renders
└── home.rs first real handler
templates/
├── base.html
├── home.html
└── error.html
That’s the whole layout. You may notice what’s missing: there is no domain/
and no db/ yet. They’re planned, but they appear in lessons 3 and 4, when
they have content. A Java project skeleton tends to open with a full set of
empty packages, each waiting for its layer; we’ll add a module the moment it
has a job and not a commit sooner.
One enum to name every failure
Java has checked exceptions, and twenty years of Java code has been trying to
escape them: wrap in RuntimeException, add throws Exception, let Spring
translate everything into its unchecked hierarchy. The instinct behind
checked exceptions was right, though. The caller should be forced to face
what can fail. Rust keeps the obligation and drops the escape hatches:
fallible functions return Result<T, E>, and you cannot touch the T
without acknowledging the E.
Here is the entire error vocabulary of the port so far, in src/web/error.rs:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("That page doesn't exist.")]
NotFound,
#[error("The page failed to render.")]
Template(#[from] askama::Error),
}
pub type AppResult<T> = Result<T, AppError>;
Two variants, because exactly two things can currently go wrong. When lesson
3 introduces the database, a Database(#[from] sqlx::Error) variant joins
them, and the enum stays what it is now: an honest, compiler-checked list of
every failure the app knows about.
The thiserror derive writes the boilerplate Display and From
implementations. That #[from] attribute matters most: it means any
askama::Error can convert into an AppError automatically, which is what
makes the ? operator work across error types.
The question mark is the whole pattern
Here’s the home handler, src/web/home.rs:
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate;
pub async fn home() -> AppResult<Html<String>> {
Ok(Html(HomeTemplate.render()?))
}
One line of body. The ? after render() reads as: if this failed, convert
the error into AppError and return it now; otherwise hand me the value. It
is the try/catch-and-rethrow you’ve written a thousand times, performed
by one character, with the conversion rules declared once on the enum instead
of at every call site.
Java developers sometimes meet ? and see ceremony. Watch what it replaces:
try {
return template.render();
} catch (TemplateException e) {
throw new AppException("render failed", e);
}
Same semantics. Four lines versus one keystroke, and the Rust version can’t forget to wrap, can’t swallow, and can’t accidentally catch too much.
Teaching failure to render itself
A returned error still has to become an HTTP response. Axum’s contract for
that is the IntoResponse trait, and implementing it for AppError is where
the error strategy meets the browser:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Template(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let page = ErrorTemplate {
status: status.as_u16(),
message: self.to_string(),
};
match page.render() {
Ok(html) => (status, Html(html)).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR,
"error page failed to render").into_response(),
}
}
}
Read the match on &self first: every variant must map to a status code,
and if a future lesson adds a variant and forgets to map it, the compiler
refuses to build. That’s the web.xml error-page table, except it can’t
drift out of date.
The last arm handles the awkward philosophical case: what if rendering the error page fails? Plain text, no recursion. Every error system needs a floor, and it’s better to choose yours than to discover it.
Wiring in src/web/mod.rs connects the dots, including the part JPetStore
never had — a fallback so unknown URLs get a designed page instead of a
container default:
pub fn router() -> Router {
Router::new()
.route("/", get(home::home))
.fallback(not_found)
}
async fn not_found() -> AppError {
AppError::NotFound
}
That fallback handler returns the error as its success value, which works
because anything implementing IntoResponse can be a handler’s return type.
A 404 in this app is not an exceptional condition; it’s a page.
Templates that fail before they ship
The three templates use Askama. The mechanic is inheritance: base.html
holds the page chrome and declares named holes,
<main>
{% block content %}{% endblock %}
</main>
and every page template names its parent and fills the holes:
{% extends "base.html" %}
{% block content %}
<h2>Under construction</h2>
<p>The catalog arrives in lesson 5. ...</p>
{% endblock %}
error.html works the same way, rendering its struct’s fields with
{{ status }} and {{ message }}. And here is the property that justifies
choosing Askama: templates compile with the program. Misspell a variable,
reference a field that doesn’t exist, break the inheritance chain, and
cargo build fails. The JSP equivalent waits until a visitor hits the page
in production and gets a stack trace with a five-hundred-line temp-class
name.
Run it and poke both paths:
cargo run
curl -i http://localhost:8081/ # 200, the under-construction page
curl -i http://localhost:8081/nope # 404, the styled error page
The unwraps that stayed
Look at main.rs in the checkpoint and you’ll find expect calls at the
bind and serve steps. That’s deliberate, and it’s the last piece of the
strategy: startup failures and request failures are different species. If
the port can’t bind 8081 there is no user to show an error page to and no
sensible way to continue, so the honest move is to print why and exit. The
rule the rest of the course follows: crash loudly before serving, never
after.
Build it
| File | Action | What goes in it |
|---|---|---|
Cargo.toml |
generated | cargo add askama thiserror |
src/web/error.rs |
write | AppError, AppResult, ErrorTemplate, the IntoResponse impl — all shown above |
src/web/home.rs |
write | The one-line handler shown above |
src/web/mod.rs |
write | The router and fallback shown above |
src/main.rs |
modify | Replace lesson 1’s body: declare mod web;, build the app via web::router(), keep the expects |
templates/base.html |
write + copy | The block structure shown above; the CSS inside it is styling — copy it from the answer key |
templates/home.html |
write | Extends base, one content block |
templates/error.html |
write | Extends base, renders {{ status }} and {{ message }}, links home |
Done when: curl -i localhost:8081/ returns 200 with the page and
curl -i localhost:8081/nope returns a styled 404.
Answer key: lesson-01...lesson-02.
Challenge: an About page
Add /about — a short page, its own template extending base.html, a link
to it from the home page. You’ll touch all three pieces of this lesson:
a route, a handler returning AppResult, and a template that the compiler
checks. Diff against lesson-02 when you’re happy; then try misspelling a
block name in your new template and watch when the failure happens.
Next lesson the thirteen tables arrive: schema, migrations, and the seed data, with SQLx checking our SQL the same way Askama just checked our HTML.