In Ktor, how do you turn a body that fails receive<T>() into a 400 with a JSON error?
answer
- the exception escapes your handler
- nothing maps it to a status by default
- install the plugin whose job is exception to response
- error DTO travels through the same converter
basics
~20 sInstall StatusPages alongside ContentNegotiation and register exception handlers for the conversion failures — Ktor's BadRequestException and the converter's own convert exception — responding with a serializable error DTO. Unhandled, those exceptions escape the handler and become 500s.
solid answer
~40 sA failed `call.receive<T>()` throws inside your handler; nothing in `ContentNegotiation` turns that into a client error, so by default the exception escapes and the engine answers 500 with no body. The fix is `install(StatusPages)` with `exception<...>` handlers: catch Ktor's `BadRequestException` (raised for a body the plugin cannot convert, usually wrapping the converter's `ContentConvertException`/`JsonConvertException`), respond `HttpStatusCode.BadRequest` with a `@Serializable` error DTO, and keep a final `exception<Throwable>` handler that logs and returns a generic 500. Two details matter in production: the error DTO is itself serialized through `ContentNegotiation`, so it must be a registered-convertible type; and you should not echo the raw exception message to clients, since deserializer messages leak field names and internal types. For semantic validation beyond "is it parseable", the `RequestValidation` plugin throws `RequestValidationException`, which you map the same way.
code
kotlin · 16 lines@Serializable
data class ApiError(val code: String, val message: String)
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.application.log.info("rejected body: ${cause.message}")
call.respond(HttpStatusCode.BadRequest, ApiError("INVALID_BODY", "Request body could not be read"))
}
exception<RequestValidationException> { call, cause ->
call.respond(HttpStatusCode.UnprocessableEntity, ApiError("VALIDATION_FAILED", cause.reasons.joinToString()))
}
exception<Throwable> { call, cause ->
call.application.log.error("unhandled", cause)
call.respond(HttpStatusCode.InternalServerError, ApiError("INTERNAL", "Unexpected error"))
}
}go deeper
Know that a body Ktor cannot convert throws, and that returning a proper 400 requires installing StatusPages and registering an exception handler.
Explain the flow: the converter throws, the exception escapes the handler, StatusPages maps type to response, and the error body is serialized by the same ContentNegotiation converter.
Demonstrate production judgment — a stable error contract with codes, no internal messages leaked, parse failures distinguished from validation failures, and logging that separates client from server errors.
Own the platform error contract: one envelope and code vocabulary across services, how clients are expected to branch on it, and the migration cost when that shape changes.
## Where the failure happens `call.receive<CreateOrder>()` runs the receive pipeline: pick a converter by `Content-Type`, deserialize, return. Every step can fail — no converter for the media type, malformed JSON, a missing required field, a string where a number belongs. All of these surface as a thrown exception inside your route handler. Ktor's `ContentNegotiation` wraps conversion problems in a bad-request-shaped exception (`io.ktor.server.plugins.BadRequestException`), typically carrying the converter's own failure — `ContentConvertException`, or `JsonConvertException` for the kotlinx converter — as the cause. What it does *not* do is decide the HTTP response for you. Without a handler, the exception propagates out of routing, the engine logs it, and the client gets a bare 500. That is the wrong status (the caller's request was wrong, not your server) and an unusable one (no body says which field). ## StatusPages is the mapping layer `StatusPages` (`io.ktor.server.plugins.statuspages`) is the plugin whose whole job is turning thrown exceptions and bare status codes into responses. Two registration forms matter here: - `exception<T> { call, cause -> ... }` — handle an exception type. - `status(HttpStatusCode.NotFound) { call, status -> ... }` — give a body to a status produced without an exception. Ktor selects the handler registered for the closest matching type, so a specific `exception<BadRequestException>` and a catch-all `exception<Throwable>` coexist: the specific one wins for conversion failures, the catch-all covers everything else. ## A contract-shaped handler The error body should be a type of your own — for example `@Serializable data class ApiError(val code: String, val message: String)` — and it is written back through `ContentNegotiation` exactly like a success body. This is worth saying out loud in an interview: your error responses depend on the same plugin as your success responses, so an error DTO that is not serializable turns a 400 into a 500 inside the error handler, which is a genuinely confusing outage. The handler should also **not** forward `cause.message` verbatim. Deserializer messages name Kotlin properties, class names and offsets. That is a fine thing to log with the correlation id and a poor thing to return to an anonymous caller. Return a stable machine-readable `code` plus a human message you control. ## Parseable is not valid A body can deserialize perfectly and still be nonsense: a negative quantity, an end date before the start date. That is not a conversion failure and `StatusPages` will never see it unless you throw. Ktor's `RequestValidation` plugin covers the common case — `install(RequestValidation) { validate<CreateOrder> { order -> if (order.quantity <= 0) ValidationResult.Invalid("quantity must be positive") else ValidationResult.Valid } }` — and raises `RequestValidationException`, which you map in `StatusPages` to a 400 (or whatever your contract says for semantic failures). Alternatively you validate by hand in the handler and throw your own exception type; the mapping mechanism is identical. ## Ordering and pipeline placement `StatusPages` intercepts around the call, so it must be installed at application level, not inside a route, if it is to cover routing failures. Installing it and `ContentNegotiation` in the same module is normal; there is no ordering requirement between them for this purpose, because the converter is invoked from inside the handler while `StatusPages` wraps the whole call. One subtlety: once a response has already been committed, a later exception cannot be converted into a different status — the headers are gone. Handlers that stream a large body and then fail cannot be rescued by `StatusPages`, which is an argument for validating early. ## Observability Map-and-log, not map-and-swallow. Every handler should log at a level appropriate to the class of failure: client errors at info or debug (they are the caller's problem and are noisy), server errors at error with the stack trace. A 400 rate that suddenly spikes usually means a client deployed a contract change, and you only see that if the mapping is instrumented. ## The interview point The question separates people who have shipped a Ktor API from people who have followed a tutorial. The shipped answer names `StatusPages`, distinguishes conversion failure from semantic validation, remembers that the error body itself goes through `ContentNegotiation`, and refuses to leak deserializer internals to the caller.
- Your StatusPages handler returns a 500 instead of the 400 you registered. What is the likely cause?Either the thrown type is not what you registered — you matched a converter-specific exception while Ktor raised its bad-request wrapper, or vice versa — or the handler itself failed, commonly because the error DTO is not serializable by the registered converter. Register both the wrapper and the converter exception, and make the error type `@Serializable`.
- How do you separate a malformed body from a well-formed but invalid one?Deserialization failures come from the converter and mean the bytes could not become the type. Semantic failures need an explicit check: Ktor's `RequestValidation` plugin, whose `validate<T>` block returns `ValidationResult.Invalid`, throws `RequestValidationException` that you map separately — often to a different status or error code than a parse failure.
- Why not just wrap every receive call in try/catch?It works but scatters the contract: every route repeats the same mapping and one forgotten catch produces an inconsistent 500. Centralising in `StatusPages` gives one error shape for the whole API, one place to change the code vocabulary, and one place to log. Local try/catch is for a route that genuinely needs a different answer.
saying these in an interview costs you the question
- Assuming ContentNegotiation returns 400 automatically
- Echoing the deserializer exception message to the caller
- Using a non-serializable error DTO in the handler
- Treating parseable JSON as validated input
- Registering only a catch-all Throwable handler and losing the 400