Centralized Error Handling in Ktor with the StatusPages Plugin
Learn how to install and configure Ktor's StatusPages plugin to convert exceptions and error statuses into consistent JSON responses, with setup steps, tests, and troubleshooting tips.
13 Mar 2026, 19:26 UTC

Desired outcome
Configure a Ktor server so that any thrown exception or non‑success HTTP status is transformed into a consistent JSON error response from a single location, eliminating repetitive try/catch blocks in each route.
Prerequisites
- A Ktor server project using Netty, CIO, or Jetty engine.
- Kotlin 1.8+ with coroutines enabled.
- The
ktor-server-status-pages artifact matching your Ktor version (e.g., io.ktor:ktor-server-status-pages:2.3.0).
- Optional but recommended: a JSON serialization feature such as
ktor-server-content-negotiation-jackson or ktor-server-content-negotiation-kotlinx-serialization.
- Build tool (Gradle or Maven) with access to Maven Central.
ktor-server-status-pages artifact matching your Ktor version (e.g., io.ktor:ktor-server-status-pages:2.3.0).ktor-server-content-negotiation-jackson or ktor-server-content-negotiation-kotlinx-serialization.Procedure
- Add the dependency (run in your project root):
# Gradle Kotlin DSL
dependencies {
implementation("io.ktor:ktor-server-status-pages:$ktor_version")
// JSON support – choose one
implementation("io.ktor:ktor-server-content-negotiation-jackson:$ktor_version")
}
Replace $ktor_version with the version you use elsewhere in the build.
- Install ContentNegotiation before StatusPages** (order matters because the handler may need to serialize a response):
install(ContentNegotiation) {
json() // uses Jackson by default; configure as needed
}
- Install and configure StatusPages**:
install(StatusPages) {
// Map specific exception types to HTTP status and body
exception<IllegalArgumentException> { call, cause ->
// Log the full stack trace server‑side (example using SLF4J)
logger.error("Bad request", cause)
// Respond with a sanitized JSON payload
call.respond(HttpStatusCode.BadRequest, mapOf(
"error" to "Invalid input",
"details" to cause.message
))
}
exception<IllegalStateException> { call, cause ->
logger.error("Internal error", cause)
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Internal server error"
))
}
// Fallback for any other Throwable not explicitly handled
exception<Throwable> { call, cause ->
logger.error("Unhandled exception", cause)
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Unexpected error"
))
}
// Uniform 404 and 500 bodies based on status code
status(HttpStatusCode.NotFound) { call, status ->
call.respond(HttpStatusCode.NotFound, mapOf(
"error" to "Resource not found",
"path" to call.request.path
))
}
status(HttpStatusCode.InternalServerError) { call, status ->
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Something went wrong"
))
}
}
Place this block inside application {} in Application.kt (or wherever you configure the engine).
- Create a test route to verify mapping** (add after plugins):
get("/bad") {
// Simulate a validation failure
throw IllegalArgumentException("username cannot be empty")
}
get("/ok") {
call.respond(mapOf("msg" to "hello"))
}
# Gradle Kotlin DSL
dependencies {
implementation("io.ktor:ktor-server-status-pages:$ktor_version")
// JSON support – choose one
implementation("io.ktor:ktor-server-content-negotiation-jackson:$ktor_version")
}
Replace $ktor_version with the version you use elsewhere in the build.
install(ContentNegotiation) {
json() // uses Jackson by default; configure as needed
}
install(StatusPages) {
// Map specific exception types to HTTP status and body
exception<IllegalArgumentException> { call, cause ->
// Log the full stack trace server‑side (example using SLF4J)
logger.error("Bad request", cause)
// Respond with a sanitized JSON payload
call.respond(HttpStatusCode.BadRequest, mapOf(
"error" to "Invalid input",
"details" to cause.message
))
}
exception<IllegalStateException> { call, cause ->
logger.error("Internal error", cause)
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Internal server error"
))
}
// Fallback for any other Throwable not explicitly handled
exception<Throwable> { call, cause ->
logger.error("Unhandled exception", cause)
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Unexpected error"
))
}
// Uniform 404 and 500 bodies based on status code
status(HttpStatusCode.NotFound) { call, status ->
call.respond(HttpStatusCode.NotFound, mapOf(
"error" to "Resource not found",
"path" to call.request.path
))
}
status(HttpStatusCode.InternalServerError) { call, status ->
call.respond(HttpStatusCode.InternalServerError, mapOf(
"error" to "Something went wrong"
))
}
}
Place this block inside application {} in Application.kt (or wherever you configure the engine).
get("/bad") {
// Simulate a validation failure
throw IllegalArgumentException("username cannot be empty")
}
get("/ok") {
call.respond(mapOf("msg" to "hello"))
}
Expected checks
- Unit test using Ktor's testApplication (run in
src/test/kotlin):
@Test
fun `bad request returns JSON error`() = testApplication {
application { /* install plugins as in main */ }
val response = handleRequest(HttpMethod.Get, "/bad")
assertEquals(HttpStatusCode.BadRequest, response.status)
val json = response.content?.readText()
assertTrue(json?.contains("Invalid input") == true)
assertTrue(json?.contains("username cannot be empty") == true)
}
@Test
fun `unmapped exception yields default 500`() = testApplication {
application { /* install plugins but do NOT map IllegalStateException */ }
val response = handleRequest(HttpMethod.Get, "/bad") { throw IllegalStateException("boom") }
assertEquals(HttpStatusCode.InternalServerError, response.status)
// Body may be empty because no mapping; verify fallback
assertTrue(response.content?.readText()?.isEmpty() == true)
}
Run with ./gradlew test. No special permissions needed beyond the ability to execute tests.
- Manual verification with curl (run from a terminal):
# Start the server (e.g., ./gradlew run)
curl -i http://localhost:8080/bad
# Expected: HTTP/1.1 400 Bad Request and a JSON body
curl -i http://localhost:8080/nonexistent
# Expected: HTTP/1.1 404 Not Found with custom JSON from status block
Check that the response headers include Content-Type: application/json (if ContentNegotiation is installed) and that the body matches the map you defined.
src/test/kotlin):
@Test
fun `bad request returns JSON error`() = testApplication {
application { /* install plugins as in main */ }
val response = handleRequest(HttpMethod.Get, "/bad")
assertEquals(HttpStatusCode.BadRequest, response.status)
val json = response.content?.readText()
assertTrue(json?.contains("Invalid input") == true)
assertTrue(json?.contains("username cannot be empty") == true)
}
@Test
fun `unmapped exception yields default 500`() = testApplication {
application { /* install plugins but do NOT map IllegalStateException */ }
val response = handleRequest(HttpMethod.Get, "/bad") { throw IllegalStateException("boom") }
assertEquals(HttpStatusCode.InternalServerError, response.status)
// Body may be empty because no mapping; verify fallback
assertTrue(response.content?.readText()?.isEmpty() == true)
}
Run with ./gradlew test. No special permissions needed beyond the ability to execute tests.
# Start the server (e.g., ./gradlew run)
curl -i http://localhost:8080/bad
# Expected: HTTP/1.1 400 Bad Request and a JSON body
curl -i http://localhost:8080/nonexistent
# Expected: HTTP/1.1 404 Not Found with custom JSON from status block
Check that the response headers include Content-Type: application/json (if ContentNegotiation is installed) and that the body matches the map you defined.
Recovery options & common pitfalls
- Empty or HTML error pages: Ensure
StatusPages is installed after any feature that may short‑circuit the pipeline (e.g., authentication) and after ContentNegotiation if you rely on JSON serialization. Reorder the install calls and restart.
- Serialization exceptions inside a handler: Keep handler logic simple; avoid calling external services or complex transformations that could throw. If a handler throws, the exception propagates outside
StatusPages and results in the default 500 with no body.
- Logging vs. client exposure: Never return the full exception message or stack trace to the client in production. Log the cause with
logger.error and return a generic or sanitized field as shown.
- Rolling back: To revert, remove the
install(StatusPages) { … } block and the ktor-server-status-pages dependency, then rebuild and restart. No data store changes are made, so rollback is purely configuration.
StatusPages is installed after any feature that may short‑circuit the pipeline (e.g., authentication) and after ContentNegotiation if you rely on JSON serialization. Reorder the install calls and restart.StatusPages and results in the default 500 with no body.logger.error and return a generic or sanitized field as shown.install(StatusPages) { … } block and the ktor-server-status-pages dependency, then rebuild and restart. No data store changes are made, so rollback is purely configuration.Limitations
The StatusPages plugin only intercepts exceptions thrown within the call pipeline (i.e., inside route blocks or features that invoke call). Errors occurring in engine‑level code, in background coroutines launched with launch outside a request, or during application startup are not caught and will produce the server’s default behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.