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.