In the Ktor client, what does expectSuccess change, and how do you handle a 404?
answer
- The default is permissive, not exceptional
- An error status is still a response
- One flag flips to exception-based handling
- Separate exception types for 4xx and 5xx
- A validator hook centralises the policy
basics
~10 sKtor's client sets expectSuccess to false by default, so a 404 returns a normal HttpResponse you must inspect via response.status. Setting expectSuccess = true makes non-2xx responses throw ClientRequestException, ServerResponseException or RedirectResponseException instead.
solid answer
~40 sSince Ktor 2.0 `expectSuccess` defaults to **false**: an error status is just a response. `client.get(url)` for a missing resource returns normally with `status == HttpStatusCode.NotFound`, and if you then call `body<Order>()` you try to deserialize the *error* payload — producing a serialization exception that hides the real problem. So with the default you check `response.status` before reading the body. Setting `expectSuccess = true`, either on the client config or on a single request builder, flips it to exception-based handling: 3xx raises `RedirectResponseException`, 4xx `ClientRequestException`, 5xx `ServerResponseException`, all subclasses of `ResponseException` and all carrying the response via `.response` so you can still read the error body. For central policy — mapping remote errors onto your own domain exceptions — use `HttpResponseValidator { validateResponse { ... }; handleResponseExceptionWithRequest { cause, request -> ... } }`.
code
kotlin · 7 linesval response = client.get("https://api.example.com/orders/999")
val order: Order? = when {
response.status.isSuccess() -> response.body()
response.status == HttpStatusCode.NotFound -> null
else -> error("orders returned ${response.status}")
}go deeper
Recall that a Ktor client call does not fail on a 404 by default — you check response.status yourself — and that expectSuccess = true switches to exceptions.
Explain the exception hierarchy (ResponseException with Redirect/ClientRequest/ServerResponse subclasses), that .response still carries the error body, and why deserializing before checking status produces a misleading error.
Demonstrate the diagnosis: a serialization exception in production that is really an upstream 500. Then show central handling with HttpResponseValidator so failures arrive as domain exceptions carrying the request URL.
Own the convention across services: one error-handling style per client, transport outcomes translated into domain results at a boundary, and dependency failures distinguishable from caller mistakes in the telemetry.
## The default A Ktor `HttpClient` created with no special configuration does **not** treat an error status as a failure. `expectSuccess` is `false` by default from Ktor 2.0 onwards (Ktor 1.x threw by default, which is why older code and older answers differ). Concretely: val response = client.get("https://api.example.com/orders/999") // returns normally; response.status == HttpStatusCode.NotFound The consequence is that correct code checks the status: when { response.status.isSuccess() -> response.body<Order>() response.status == HttpStatusCode.NotFound -> null else -> error("orders returned ${response.status}") } ## The trap this default sets If you skip the check and go straight to `response.body<Order>()`, you feed the server's *error* payload — an error envelope, an HTML page, an empty body — into the converter for `Order`. What surfaces is a serialization exception complaining about a missing field or unexpected token. The stack trace points at deserialization; the actual fault is a 500 upstream. Recognising that symptom is most of the value of this question. ## Turning it on val client = HttpClient(CIO) { expectSuccess = true } Now a non-2xx response raises an exception from the client call itself. It can also be set for one request only, because `expectSuccess` exists on the request builder: client.get(url) { expectSuccess = true } ## The exception hierarchy All of these extend `ResponseException`: - `RedirectResponseException` — a 3xx that reached the caller. With redirect following enabled (the default), you rarely see this. - `ClientRequestException` — 4xx. The remote says the request was wrong: bad input, missing auth, absent resource. - `ServerResponseException` — 5xx. The remote failed. The split is deliberately useful: a 4xx usually means fix the request, a 5xx usually means the dependency is unwell. Catching `ClientRequestException` separately from `ServerResponseException` lets those two paths diverge without string-matching on status codes. ## Reading the error body `ResponseException` exposes `.response`, the full `HttpResponse`. So the error payload is still available: try { client.get(url).body<Order>() } catch (e: ClientRequestException) { val detail = e.response.bodyAsText() ... } This matters because most APIs put the actionable information — a validation message, an error code — in the body, and an exception message alone will not carry it. ## HttpResponseValidator For cross-cutting policy, the client configuration block accepts a validator: HttpResponseValidator { validateResponse { response -> if (response.status == HttpStatusCode.Conflict) throw DuplicateOrder() } handleResponseExceptionWithRequest { cause, request -> throw OrderServiceUnavailable(request.url.toString(), cause) } } `validateResponse` inspects every response and may throw; `handleResponseExceptionWithRequest` intercepts exceptions (including network-level ones) together with the request that caused them, which is what lets you attach the URL to the message. Note that `expectSuccess = true` is itself implemented as a default validator, so these mechanisms are the same machinery, not competing ones. ## Which style to choose Both are defensible, and consistency is what actually matters: - **Status checking** keeps expected non-2xx outcomes — a 404 meaning "absent", a 409 meaning "already exists" — as ordinary values rather than exceptions, which reads well when those outcomes are part of the contract. - **`expectSuccess = true`** removes the chance of forgetting a check and gives one catch site per call, which reads well when any non-2xx is genuinely exceptional. The worst outcome is mixing them per call site so that no reader knows whether a given call throws. Deciding once per client — and, for a real integration, wrapping the client in a typed API layer that translates transport outcomes into domain results — is the shape to aim for. ## Common mistakes Assuming Ktor throws on 4xx by default (Ktor 1.x habits); deserializing before checking the status; catching `Exception` around a call and losing the 4xx/5xx distinction; and discarding `.response` so the error body is never read.
- Can expectSuccess be enabled for a single Ktor client request rather than the whole client?Yes — expectSuccess is a property on the request builder as well as on the client configuration, so `client.get(url) { expectSuccess = true }` opts one call into exception-based handling. Mixing styles per call site is best avoided, but it is useful when one endpoint has genuinely different semantics.
- Why does body<Order>() sometimes throw a serialization error when the server actually returned 500?With the default expectSuccess = false, the 500 response is returned normally and its error payload is handed to the Order converter. The converter fails on the unexpected shape, so the visible exception is about deserialization while the real fault is the upstream failure. Checking status first, or enabling expectSuccess, makes the true cause visible.
- What does HttpResponseValidator give you that a try/catch at each call site does not?One place to express policy for every call the client makes: validateResponse can reject responses that are technically 2xx but wrong, and handleResponseExceptionWithRequest sees both the exception and the originating request, so you can translate transport failures into domain exceptions carrying the URL. Call sites then handle your types, not Ktor's.
saying these in an interview costs you the question
- Assumes Ktor throws on 4xx by default
- Deserializes the body before checking the status
- Catches Exception and loses the 4xx/5xx split
- Ignores ResponseException.response and the error body
- Mixes throwing and status-checking styles per call