1. Basic resilience model in networking
If you previously wrote programs that count numbers or sort lists, the world was relatively honest: if the code compiles and doesn’t divide by zero, it usually works. With networking, the world suddenly becomes like pizza delivery: you placed an order, but that doesn’t guarantee it will arrive, let alone on time. Our task is to learn to write code that reacts to such situations predictably and calmly.
Key thought for the day: a network error is not a “rare edge case”, but one of the normal scenarios. That’s why error handling is not something you “add later”, but part of the function design: what it returns, what it prints (or doesn’t print), and what decisions the calling code makes.
Two different types of problems: non‑2xx and exceptions
When you make an HTTP request, trouble comes in two fundamentally different forms.
The first type — the server responded, but the response is “unsuccessful”: status 400/404/500, etc. It is still a normal HttpResponse. It arrived. It has status, often has a body, and sometimes even JSON (especially if the server likes structured errors).
The second type — there was no response as a fact: timeout, failed to connect, connection dropped. In this case you usually get an exception, and your code doesn’t even reach HttpResponse. In Kotlin, these situations are handled via try/catch, and try/catch can be an expression and return a value — this is convenient for compact “success/error” code.
To avoid confusion, keep a mini table in your head:
| Situation | What happened “in fact” | How to handle |
|---|---|---|
|
The server responded, but the resource was not found | Branch on response.status (non‑2xx) |
|
The server responded, but it has a problem | Branch on response.status (often retryable) |
| Timeout | No response | try/catch (exception) |
| No network / DNS | No response | try/catch (exception) |
A short “skeleton” of the right way of thinking looks like this:
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
suspend fun call(client: HttpClient): HttpResponse {
return client.get("https://api.example.com/ping")
}
If call(...) returned HttpResponse, that means the network “as a fact” worked, and then you decide based on status. If it didn’t return, you’re in the world of exceptions.
Handling pattern: “status → branch → body”
In practice, beginners often fall into a trap: “well, if I received text/JSON, then everything is fine”. With HTTP you can’t do that: the body can be an error message, an HTML 404 page, a JSON error, anything. So the “status first, meaning second” style is your insurance against weird bugs.
Let’s make a small helper to make the code easier to read:
import io.ktor.http.*
fun HttpStatusCode.isSuccessful(): Boolean {
return value in 200..299
}
Now the check turns into a readable phrase:
import io.ktor.client.statement.*
suspend fun printStatusAndBody(response: HttpResponse) {
val bodyText = response.bodyAsText()
println("status=${response.status.value}") // status=200 (example)
println("body=$bodyText")
}
Notice a subtle point: bodyAsText() means “read the body”. Typically you read the body once. So it’s better to structure the code so that in each branch you read the body once and then work with the obtained value.
Timeouts: an honest “didn’t make it in time” is better than waiting forever
Without a timeout, a network request can wait for a very long time. Sometimes it looks like “the program froze”, although in reality it is patiently waiting. The user (or you) at that moment thinks: “well, that’s it, Kotlin broke”, but Kotlin has nothing to do with it — it’s just too polite.
A timeout is a contract: how long we are willing to wait. If we don’t make it — we treat it as a request execution error (that is, an exception) and handle it in catch.
In Ktor, this is usually done by installing the HttpTimeout plugin on the client. Important: the exact timeout fields depend on the Ktor version, but the general idea is the same — limit the waiting.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
fun buildClientWithTimeout(): HttpClient {
return HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 3_000
}
}
}
Why does this relate to “resilience”? Because with a timeout you get predictable behavior: in 3_000 milliseconds you either get a response or move into the error branch and can show a clear message, retry, suggest the user try again later, and so on.
2. Result as a value and limited retries
Return the result as a value: sealed instead of println in catch
When you’re just starting out, you really want to do this:
try {
// ...
} catch (e: Exception) {
println("Error: ${e.message}")
}
This is fine for the first steps, but very quickly it turns the code into a mess: the function seems to “do something”, but the result is unclear, the calling code doesn’t know whether it worked or not, and messages get printed from different places.
Much calmer — return the result as an explicit value, for example via a sealed class. And then in one place decide what to print.
Let’s make a minimal result for a network call:
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data class HttpError(val status: Int, val body: String) : ApiResult<Nothing>()
data class NetworkError(val message: String) : ApiResult<Nothing>()
}
Why is sealed convenient here? Because later we can handle the result via when and get exhaustive branches (the compiler will help you not forget a case). For when as an expression there is a rule: if you use it as an expression (assign its result), it must cover all variants, otherwise the compiler will complain.
“Safe GET”: non‑2xx separately, exceptions separately
Let’s assemble everything into a neat function. As an example, we’ll continue the “user by id” style that you already saw in the previous lecture about JSON integration.
Model:
import kotlinx.serialization.Serializable
@Serializable
data class UserResponse(
val id: Int,
val name: String
)
And now — the fetchUserSafe function. The key point here: non‑2xx is not catch. It’s a regular branch based on response.status.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
suspend fun fetchUserSafe(client: HttpClient, id: Int): ApiResult<UserResponse> {
return try {
val response: HttpResponse = client.get("https://api.example.com/users/$id")
if (response.status.isSuccessful()) {
val user: UserResponse = response.body()
ApiResult.Ok(user)
} else {
val errorText = response.bodyAsText()
ApiResult.HttpError(status = response.status.value, body = errorText)
}
} catch (e: Exception) {
ApiResult.NetworkError(message = e.message ?: "network error")
}
}
Notice how cleanly the two worlds split: “the response arrived, but it’s bad” and “there was no response”. That’s exactly the boundary we want.
And one more important thought: try/catch in Kotlin can be treated as an expression that returns a value. It’s a direct language tool to “return Ok or return Error”.
Limited retry: repeat the request, but don’t turn into a woodpecker
Retry is useful when the problem is temporary. But if you make retry “forever”, your app will become a perpetual motion machine… except it will spend internet and nerves.
There are two sane limits here: a limit on the number of attempts and a small pause between attempts.
Let’s make a simple retry function that runs a block several times. I’m deliberately keeping it short so we don’t drown in details.
import kotlinx.coroutines.delay
suspend fun <T> retry(
attempts: Int,
delayMillis: Long,
block: suspend () -> T
): T {
var lastError: Exception? = null
repeat(attempts) {
try {
return block()
} catch (e: Exception) {
lastError = e
delay(delayMillis)
}
}
throw lastError ?: IllegalStateException("Unknown error")
}
Yes, this version catches “all exceptions” — because right now we’re learning the mechanics. In a real project you usually don’t want to retry everything (for example, retrying a JSON parsing error is pointless). But the basic pattern you need is exactly this: limited and with a pause.
The diagram of what happens looks almost like a flowchart “try again, but not infinitely”:
flowchart TD
A[Attempt #1] -->|success| OK[Returned result]
A -->|exception| D[delay]
D --> B[Attempt #2]
B -->|success| OK
B -->|exception| D2[delay]
D2 --> C[Attempt #N]
C -->|success| OK
C -->|exception| FAIL[Throw the last error]
Now the most interesting part: what exactly are we retrying? Answer: most often it makes sense to retry only “network failures” (exceptions) and sometimes 5xx (server errors, because the server might have “blinked”). But retrying 4xx is usually pointless: if you sent invalid data, repeating the same thing won’t suddenly become valid.
We’re not building a complex classification yet (that’s a separate big topic), but you can already apply a simple rule: exceptions are a retry candidate, HttpError(4xx) usually is not.
Retry on top of fetchUserSafe: how to “glue” two tools neatly
To avoid smearing retry throughout the code, it’s easier to wrap the “network call” itself. For example, like this:
suspend fun fetchUserWithRetry(client: HttpClient, id: Int): ApiResult<UserResponse> {
return try {
retry(attempts = 3, delayMillis = 300) {
val r = fetchUserSafe(client, id)
// Retry only network errors, not HTTP responses
if (r is ApiResult.NetworkError) {
throw IllegalStateException(r.message)
}
r
}
} catch (e: Exception) {
ApiResult.NetworkError(message = e.message ?: "network error")
}
}
The code looks a bit “tricky”, but the idea is simple: if the result is NetworkError, we temporarily turn it into an exception so that retry kicks in. Everything else we return as-is.
Yes, you can make it nicer (for example, so that retry works with ApiResult directly), but right now it’s more important to understand the principle and not drown in engineering perfectionism.
How to show the result to the user: one place, one style
Now we can make the cleanest possible logic in main: get ApiResult and turn it into text.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildClientWithTimeout()
try {
val result = fetchUserWithRetry(client, id = 42)
val message = when (result) {
is ApiResult.Ok -> "User: ${result.value.name}" // User: Ada (example)
is ApiResult.HttpError -> "HTTP ${result.status}: ${result.body}"
is ApiResult.NetworkError -> "Network error: ${result.message}"
}
println(message)
} finally {
client.close()
}
}
Why is finally good? Because resources must be closed reliably, even if an error happens somewhere along the way. finally exists exactly for “will run in any case”.
3. Typical mistakes when handling network errors and retries
Mistake #1: assuming non‑2xx will be “caught in catch”.
This is one of the most common logical confusions. If the server returned 404 or 500, you have an HttpResponse, and that is not an exception by itself. If you handle such cases only via catch, you either won’t handle them at all, or you’ll start hacking around, losing the meaning of the status. The correct path is to branch on response.status, and keep catch for situations where there is no response as a fact.
Mistake #2: “if a body arrived, then it’s a success”.
The body can be anything: JSON with an error, HTML, an empty string, a “not found” message. The only reliable entry point is the status. Status first, meaning second. Especially if you do response.body<T>(): on an error status the body format may be different, and you’ll get a parsing exception where you actually just needed to show HTTP 404.
Mistake #3: no timeout and the feeling that “the program froze”.
Without a timeout you don’t control the waiting time. This is not only a UX problem (“nothing is happening”), but also a technical one: stuck requests hold resources, create a queue of tasks, and worsen app performance. A timeout makes behavior predictable: either you made it in time, or you handle the error.
Mistake #4: infinite retry (while(true) + “maybe it will work”).
Such code looks like optimism, but behaves like a mini attack on the server (and on your laptop battery). Any retry should be limited by the number of attempts and have a pause. Otherwise, in case of a real problem you won’t “help” — you’ll just create noise.
Mistake #5: retrying everything, including 4xx.
If you sent an invalid request, repeating it 10 more times is not persistence, it’s stubbornness. 4xx usually means a problem in the request (data/access/format), not temporary network instability. In the basic version of the app, retry only network exceptions; return HttpError immediately.
Mistake #6: printing errors inside network functions.
When fetchUserSafe() does println(...) internally, the calling code loses control: it can’t choose the message style, language, format, and can’t, for example, show the error to the user differently. It is much more resilient to return ApiResult and decide “what to print” in one place, using when with exhaustive branches.
Mistake #7: forgetting about finally and not closing resources.
Even in small examples it’s important to build the habit: resources are closed reliably. finally runs regardless of whether there was an error or not, and is perfect for client.close().
GO TO FULL VERSION