Centralizing HTTP Error Handling in Ktor with StatusPages
Replace scattered route-level try-catch blocks with Ktor's StatusPages plugin to keep HTTP error contracts consistent and reduce boilerplate.
16 Mar 2026, 02:17 UTC

The patchwork problem
Route-level error handling starts simply. A route parses a parameter, catches an IllegalArgumentException, and returns a 400. A few routes later, another handler catches the same exception and returns a 422 with a different message shape. Over time, every endpoint becomes its own error contract, and clients end up writing defensive parsers for every possible response body.
The symptoms are predictable: inconsistent status codes for the same failure mode, stack traces leaking into production responses, and JSON error shapes that drift apart. A client that can parse {"error": "bad input"} from one route may receive {"message": "invalid"} from another, or a raw exception string with a 500 status.
StatusPages as a single interception point
Ktor's StatusPages plugin gives you one place to map failures to HTTP semantics. Installed once in your application module, it observes uncaught exceptions and explicit status codes after routing and before the response is flushed. Instead of wrapping each route body in a try-catch, you register typed handlers centrally.
The plugin exposes two primary hooks. The exception<T> builder catches a specific exception type, while the status builder intercepts a specific HTTP status code—useful for customizing default responses like 404 without touching individual route bodies.
Worked example: removing local try-catch
Imagine a route that validates an ID parameter. The local approach wraps the logic and manually maps the failure:
get("/items/{id}") {
try {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid ID")
// ... repository lookup
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("bad_request", e.message))
}
}
With StatusPages, the route body shrinks to its happy path, and the mapping moves to the plugin configuration:
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("invalid_input", "The request could not be processed")
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ErrorResponse("not_found", "Resource not found"))
}
}
get("/items/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid ID")
// ... repository lookup
}
The ErrorResponse payload stays identical across every failure, with a stable machine-readable code field and a human-readable message. Clients can switch on code without parsing free-form text.
Limitations and trade-offs
Centralization has boundaries. StatusPages handles failures during request routing, but it does not catch exceptions in background coroutines, scheduled tasks, or server pipeline phases outside the routing call. If you launch a fire-and-forget coroutine inside a route, its failure will bypass the plugin entirely. Logging and monitoring still need separate hooks—pair the plugin with a logging interceptor so centralized handlers do not become the only audit trail.
There is also an information-leakage temptation. Because handlers are centralized, it is tempting to return verbose exception messages. Production handlers should map internal exceptions to safe, generic client messages while logging details server-side.
Two more cautions apply. First, the StatusPages API has evolved across Ktor major versions; verify the exact DSL syntax for your version rather than assuming the example above compiles unchanged. Second, if an exception is thrown inside a StatusPages handler itself, it may bypass the plugin and crash the call. Keep handlers defensive and simple.
What to do next
If your routes are littered with local try-catch blocks, start by inventorying the exception types that cross your HTTP boundary. Move the most common ones—validation failures, not-found conditions—into a single StatusPages configuration. Remove the local handlers and verify that clients still receive the same status codes and body shapes.
To confirm the refactor actually changed the contract, run a request that triggers the exception with the plugin installed, then temporarily remove the plugin and observe the default 500 behavior. That contrast tells you whether your centralized mapping is doing the work you expect.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.