One Place for Things Going Wrong: StatusPages


Look back at the CRUD routes and you’ll see the same defensive lines over and over: parse the id or 400, find the task or 404. That noise crowds out the actual logic, and every handler reinvents its error format. The StatusPages plugin fixes both — you throw a meaningful exception, and one central place turns it into the right response.

Install StatusPages

It’s a plugin, so it installs like the others — give it its own config function per the structure post:

// plugins/StatusPages.kt
import io.ktor.server.plugins.statuspages.*

fun Application.configureStatusPages() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Something went wrong"))
        }
    }
}

exception<T> { call, cause -> } registers a handler for any exception of type T thrown anywhere downstream — in a route, a repository, deep in a service. That catch-all Throwable block is your safety net: it guarantees an unexpected error becomes a clean 500 with a generic message instead of leaking a stack trace to the client.

A consistent error shape

Decide once what an error looks like on the wire, as a serializable model:

@Serializable
data class ErrorResponse(val error: String)

Every error your API returns now has the same shape, so clients can handle failures uniformly. Real APIs often add a code or field-level details; keep it simple to start.

Domain exceptions

Define exceptions that describe what went wrong in your domain, not in HTTP terms:

class NotFoundException(message: String) : Exception(message)
class ValidationException(message: String) : Exception(message)

Then map each to its status code in StatusPages:

install(StatusPages) {
    exception<NotFoundException> { call, cause ->
        call.respond(HttpStatusCode.NotFound, ErrorResponse(cause.message ?: "Not found"))
    }
    exception<ValidationException> { call, cause ->
        call.respond(HttpStatusCode.BadRequest, ErrorResponse(cause.message ?: "Invalid request"))
    }
    exception<Throwable> { call, cause ->
        call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Something went wrong"))
    }
}

The mapping from domain concept to HTTP status now lives in exactly one file. Ktor matches the most specific exception type, so a NotFoundException hits its own handler, not the Throwable fallback.

Routes get to be about the happy path

With that in place, the routes shed their defensive clutter. Compare the CRUD get-by-id from before with its new form:

get("/{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
        ?: throw ValidationException("id must be a number")
    val task = repo.find(id)
        ?: throw NotFoundException("No task with id $id")
    call.respond(task)
}

Still two guard lines, but now they throw instead of hand-crafting a response — so the status code, the JSON shape, and the message format are all decided centrally and consistently. A small helper makes the id-parsing reusable across every route that needs it:

fun ApplicationCall.intParam(name: String): Int =
    parameters[name]?.toIntOrNull()
        ?: throw ValidationException("$name must be a number")
get("/{id}") {
    val task = repo.find(call.intParam("id"))
        ?: throw NotFoundException("No task with id ${call.parameters["id"]}")
    call.respond(task)
}

Status handlers, not just exceptions

StatusPages can also intercept responses by status code, regardless of how they arose. Handy for giving Ktor’s automatic 404 (an unmatched route) the same JSON shape as your thrown ones:

install(StatusPages) {
    status(HttpStatusCode.NotFound) { call, status ->
        call.respond(status, ErrorResponse("Resource not found"))
    }
    // ... exception handlers ...
}

Now even a request to a path that matches no route comes back as your ErrorResponse, not Ktor’s default text.

Don’t leak internals

One rule, stated plainly: the Throwable handler should never echo cause.message to the client. An exception from deep in a database driver can carry connection strings, query fragments, or internal paths. Log the full detail server-side (next-but-one post), respond with something generic:

exception<Throwable> { call, cause ->
    call.application.log.error("Unhandled exception", cause)   // full detail in logs
    call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Something went wrong"))
}

Final thoughts

StatusPages is the inversion that makes a Ktor codebase pleasant: routes describe the happy path and throw when reality disagrees, and a single plugin decides what every failure looks like on the wire. The payoff compounds — add the plugin once, and every route you write afterward gets consistent, safe error handling for free, while staying focused on what it’s actually trying to do.

Don’t forget to wire it in: configureStatusPages() in your module(). Next: persistence with Exposed — swapping that in-memory hash map for a real database, without the routes noticing.

Comments