DEV Community

Cover image for Bulletproof Error Handling in Kotlin Multiplatform with ResponseResult and safeRequest
Shiva Thapa
Shiva Thapa

Posted on • Edited on • Originally published at Medium

Bulletproof Error Handling in Kotlin Multiplatform with ResponseResult and safeRequest

Part 2 of 4 — This part builds on the Ktor HttpClient setup from Part 1. If you're jumping in here, the previous article covers the client configuration this layer sits on top of.


In Part 1, we built an HttpClient that handles authentication, retries, timeouts, and logging automatically. Every request leaves the client properly configured. But when the response comes back — what happens then?

In most codebases I've seen, the answer is: it depends on who wrote the repository. One repository wraps everything in a try-catch and returns null on failure. Another throws exceptions and lets the ViewModel sort it out. A third returns a Boolean. Over time, the UI layer ends up with an inconsistent patchwork of null checks, exception handlers, and loading state flags scattered across every screen.

The architecture in this series solves that problem at the data layer. The goal is simple: every network call, regardless of what it fetches or which repository it lives in, returns the same type. The UI always knows exactly what shape of data to expect. And nobody writes a try-catch in a repository ever again.

The two pieces that make this work are ResponseResult and safeRequest. Let's build them.


The Problem with Raw Exceptions

When you call client.get("some/endpoint") in Ktor, a lot can go wrong. The network might be down. The server might return a 403. The JSON might have a field with the wrong type. The request might time out. Each of these is a different exception class — IOException, ClientRequestException, SerializationException, HttpRequestTimeoutException, and more.

If you let those propagate up as exceptions, your ViewModel has to catch them. But which ones? What does it do with them? How does it distinguish between "no internet" and "user is unauthorized" and "server is down"? How does it get a user-friendly error message?

The answer shouldn't be: figure it out at call sites. The answer should be: wrap everything at the boundary, classify it, and give callers structured results.

That's exactly what ResponseResult does.


ResponseResult — The Return Type for Every Network Call

@Serializable
sealed class ResponseResult<out T> {

    @Serializable
    data class Success<out T>(val data: T? = null) : ResponseResult<T>()

    @Serializable
    data class Error(
        val message: String,
        val code: Int? = null,
        val serverMessage: String? = null,
        val errorType: ErrorType? = null
    ) : ResponseResult<Nothing>()

    @Serializable
    data object Empty : ResponseResult<Nothing>()

    @Serializable
    enum class ErrorType {
        NETWORK,
        SERVER,
        CLIENT,
        SERIALIZATION,
        TIMEOUT,
        UNAUTHORIZED,
        FORBIDDEN,
        NOT_FOUND,
        PAYMENT_NOT_VERIFIED, // domain-specific — adapt to your own app
        TICKET_NOT_GENERATED, // domain-specific — adapt to your own app
        UNKNOWN
    }

    companion object {
        fun <T> success(data: T? = null): ResponseResult<T> = Success(data)
        fun error(
            message: String,
            code: Int? = null,
            serverMessage: String? = null,
            errorType: ErrorType? = null
        ): ResponseResult<Nothing> = Error(message, code, serverMessage, errorType)
        fun empty(): ResponseResult<Nothing> = Empty
    }
}
Enter fullscreen mode Exit fullscreen mode

Three states. That's all a network call can return.

Success<T> wraps the deserialized response body. The data is nullable because some successful responses carry no body — a 204 No Content, for instance. We model that with Empty rather than Success(null), but the nullable type gives you flexibility when mapping between layers.

Error carries four pieces of information. message is a user-facing string — something you can show directly on screen. serverMessage is the raw message from the server's error response, if one exists. code is the HTTP status code. And errorType is an enum that lets the UI or ViewModel branch on the kind of failure without string-matching error messages.

Empty represents a successful response with no body. This is meaningfully different from an error and deserves its own state.

The ErrorType Enum

The ErrorType enum is one of those things that looks like overkill until you actually need it. Consider what the UI needs to do differently for each of these:

  • UNAUTHORIZED — redirect to login
  • NETWORK — show "check your connection"
  • SERVER — show "something went wrong on our end, try again"
  • TIMEOUT — show "that took too long, try again"
  • NOT_FOUND — show "this content doesn't exist anymore"
  • PAYMENT_NOT_VERIFIED, TICKET_NOT_GENERATED — domain-specific flows unique to your app

Without ErrorType, the UI has to match on status codes or parse error strings to drive different behaviours. That's fragile. With ErrorType, you just when on it.

The custom types at the bottom (PAYMENT_NOT_VERIFIED, TICKET_NOT_GENERATED) are a production detail worth noticing. When your backend returns non-standard status codes for business logic errors — like 402 for an unverified payment or 424 for a failed ticket — you map them to domain-specific error types here. Replace these with whatever your own backend uses.

Transformation Operators

ResponseResult ships with a set of transformation functions that make it composable:

// Transform success data to another type — errors pass through unchanged
inline fun <R> map(transform: (T) -> R): ResponseResult<R>

// Chain results — useful when one result feeds into another
inline fun <R> flatMap(transform: (T) -> ResponseResult<R>): ResponseResult<R>

// Fold into a single value across all three states
inline fun <R> fold(
    onSuccess: (T?) -> R,
    onError: (message: String, code: Int?, serverMessage: String?, errorType: ErrorType?) -> R,
    onEmpty: () -> R
): R

// Inline callbacks for chaining
inline fun onSuccess(block: (T?) -> Unit): ResponseResult<T>
inline fun onError(block: (message: String, code: Int?, serverMessage: String?, errorType: ErrorType?) -> Unit): ResponseResult<T>
inline fun onEmpty(block: () -> Unit): ResponseResult<T>

// Extraction helpers
fun getOrNull(): T?
fun getOrElse(default: T): T
fun getOrThrow(): T
Enter fullscreen mode Exit fullscreen mode

The map function is the one you'll use constantly in repositories. When your API returns a ResponseResult<UserDto> and you need to return a ResponseResult<User>, you call .map { it.toDomain() }. The mapping only runs if the result is a Success — errors and empty states pass through unchanged. No try-catch, no null check.

In a repository, this looks like:

override suspend fun fetchBanners(): ResponseResult<List<Banner>> {
    val response: ResponseResult<List<BannerDto>> =
        networkClient.safeRequest {
            url("some/endpoint/")
            method = HttpMethod.Get
        }
    return response.map { it.toDomain() }
}
Enter fullscreen mode Exit fullscreen mode

The safeRequest call returns a ResponseResult<List<BannerDto>>. The .map transforms the DTO list into domain models. If the request failed, the Error passes straight through — the mapper function never executes. The caller gets back clean domain types with zero extra handling.


safeRequest — The Safety Net Around Every Request

safeRequest is an extension function on HttpClient. It's the only way we make HTTP requests in this codebase:

suspend inline fun <reified T> HttpClient.safeRequest(
    requestBuilder: HttpRequestBuilder.() -> Unit
): ResponseResult<T> {
    return try {
        val response = request { requestBuilder() }
        handleResponse<T>(response)
    } catch (e: Exception) {
        handleException(e)
    }
}
Enter fullscreen mode Exit fullscreen mode

Three lines. Execute the request, handle the response, or catch any exception that escapes. The caller configures the request through requestBuilder — URL, method, body, parameters — and gets back a typed ResponseResult<T>.

Handling the Response

When the request completes without throwing, handleResponse maps the HTTP status code to the right ResponseResult:

suspend inline fun <reified T> handleResponse(response: HttpResponse): ResponseResult<T> {
    return try {
        when (response.status.value) {
            200, 201 -> ResponseResult.success(response.body<T>())

            204 -> ResponseResult.empty()

            401 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "AuthError",
                    message = "Token expired or invalid",
                    userMessage = "Your session has expired. Please log in again to continue.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.UNAUTHORIZED
                )
            }

            403 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "ForbiddenError",
                    message = "Access denied for resource",
                    userMessage = "You don't have permission to access this.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.FORBIDDEN
                )
            }

            404 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "NotFoundError",
                    message = "Resource not found at ${response.request.url}",
                    userMessage = "We couldn't find what you're looking for. It may have been moved or removed.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.NOT_FOUND
                )
            }

            429 -> {
                val retryAfter = response.headers["Retry-After"]?.toLongOrNull() ?: 30L
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "RateLimitError",
                    message = "Rate limit exceeded. Retry after $retryAfter seconds",
                    userMessage = "You're doing that too fast. Please wait $retryAfter seconds and try again.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.CLIENT
                )
            }

            402 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "PaymentNotVerified",
                    message = "Payment Not Verified",
                    userMessage = "We couldn't verify your payment. Please check your payment details or contact support.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.PAYMENT_NOT_VERIFIED
                )
            }

            424 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "TicketNotGenerated",
                    message = "Ticket not generated",
                    userMessage = "Your payment was successful, but we ran into a problem issuing your ticket. Please contact support.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.TICKET_NOT_GENERATED
                )
            }

            in 400..499 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "ClientError",
                    message = "Client error",
                    userMessage = "Something went wrong with your request. Please try again or contact support.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.CLIENT
                )
            }

            in 500..599 -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "ServerError",
                    message = "Server error",
                    userMessage = "We're experiencing a temporary issue on our end. Please try again in a few moments.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.SERVER
                )
            }

            else -> {
                val serverMessage = runCatching {
                    response.body<DefaultResponseDto<String>>().message
                }.getOrNull()
                logAndReturnError(
                    tag = "UnexpectedStatus",
                    message = "Unexpected response status: ${response.status}",
                    userMessage = "Something unexpected happened. Please try again or contact support.",
                    serverMessage = serverMessage,
                    statusCode = response.status.value,
                    errorType = ResponseResult.ErrorType.UNKNOWN
                )
            }
        }
    } catch (e: Exception) {
        handleException(e)
    }
}
Enter fullscreen mode Exit fullscreen mode

A few patterns here worth calling out.

The serverMessage extraction uses runCatching:

val serverMessage = runCatching {
    response.body<DefaultResponseDto<String>>().message
}.getOrNull()
Enter fullscreen mode Exit fullscreen mode

When a request fails, the server usually returns a JSON body explaining why — something like { "message": "This email is already registered." }. That message is often more informative than a generic "client error" string. But the error response might not always be parseable — the server might be down and return HTML, or the body might be empty. runCatching { }.getOrNull() extracts the server message silently when it's there, and returns null when it's not.

This is the purpose of DefaultResponseDto — a simple wrapper that matches the standard error envelope:

@Serializable
data class DefaultResponseDto<T>(
    @SerialName("data")    val data: T? = null,
    @SerialName("message") val message: String? = null,
    @SerialName("type")    val type: String? = null
)
Enter fullscreen mode Exit fullscreen mode

If your backend uses a different envelope shape, adjust DefaultResponseDto accordingly. The pattern stays the same.

Specific status codes are handled before ranges. 402 and 424 are handled before the 400..499 catch-all. Kotlin's when evaluates branches top-to-bottom, so the specific cases win. This is how you map non-standard business logic codes to domain-specific ErrorType values without breaking the general client error handling.

The Retry-After header is read on 429:

val retryAfter = response.headers["Retry-After"]?.toLongOrNull() ?: 30L
Enter fullscreen mode Exit fullscreen mode

When a server enforces rate limits, it often tells you how long to wait. Including that duration in the user-facing message is a small touch that makes a real difference in UX.


Handling Exceptions

When the request throws — not a failed HTTP response, but an actual exception — handleException classifies it:

@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> handleException(e: Exception): ResponseResult<T> {
    return when (e) {
        is SerializationException -> logAndReturnError(
            tag = "SerializationError",
            message = "Serialization failed: ${e.message}",
            userMessage = "We received an unexpected data format. Please try again or update the app.",
            exception = e,
            errorType = ResponseResult.ErrorType.SERIALIZATION
        )
        is JsonConvertException -> logAndReturnError(
            tag = "JsonConvertError",
            message = "JSON conversion failed: ${e.message}",
            userMessage = "We had trouble reading the server's response. Please try again.",
            exception = e,
            errorType = ResponseResult.ErrorType.SERIALIZATION
        )
        is MissingFieldException -> logAndReturnError(
            tag = "MissingFieldError",
            message = "Missing required field(s): ${e.missingFields.joinToString()}",
            userMessage = "We received incomplete information from the server. Please try again.",
            exception = e,
            errorType = ResponseResult.ErrorType.SERIALIZATION
        )
        is IOException -> logAndReturnError(
            tag = "NetworkIOError",
            message = "Network I/O error: ${e.message}",
            userMessage = "Couldn't connect to the server. Please check your internet connection and try again.",
            exception = e,
            errorType = ResponseResult.ErrorType.NETWORK
        )
        is SocketTimeoutException -> logAndReturnError(
            tag = "SocketTimeoutError",
            message = "Socket timeout: ${e.message}",
            userMessage = "The server is taking too long to respond. Please check your connection and try again.",
            statusCode = 408,
            exception = e,
            errorType = ResponseResult.ErrorType.TIMEOUT
        )
        is ConnectTimeoutException -> logAndReturnError(
            tag = "ConnectTimeoutError",
            message = "Connection timeout: ${e.message}",
            userMessage = "We couldn't reach the server. Please check your internet connection and try again.",
            statusCode = 408,
            exception = e,
            errorType = ResponseResult.ErrorType.TIMEOUT
        )
        is HttpRequestTimeoutException -> logAndReturnError(
            tag = "RequestTimeoutError",
            message = "Request timeout: ${e.message}",
            userMessage = "The request is taking longer than expected. Please try again in a moment.",
            statusCode = 408,
            exception = e,
            errorType = ResponseResult.ErrorType.TIMEOUT
        )
        is UnresolvedAddressException -> logAndReturnError(
            tag = "UnresolvedAddressError",
            message = "Failed to resolve address: ${e.message}",
            userMessage = "Couldn't reach the server. Please check your internet connection and try again.",
            exception = e,
            errorType = ResponseResult.ErrorType.NETWORK
        )
        is ClientRequestException -> logAndReturnError(
            tag = "ClientRequestError",
            message = "Client request failed with status ${e.response.status.value}",
            userMessage = "Something went wrong with your request. Please try again or contact support.",
            statusCode = e.response.status.value,
            exception = e,
            errorType = ResponseResult.ErrorType.CLIENT
        )
        is ServerResponseException -> logAndReturnError(
            tag = "ServerResponseError",
            message = "Server error with status ${e.response.status.value}",
            userMessage = "We're experiencing a temporary issue on our end. Please try again shortly.",
            statusCode = e.response.status.value,
            exception = e,
            errorType = ResponseResult.ErrorType.SERVER
        )
        is CancellationException -> logAndReturnError(
            tag = "CancellationError",
            message = "Request was cancelled: ${e.message}",
            userMessage = "The request was cancelled. Please try again if this wasn't intentional.",
            exception = e,
            errorType = ResponseResult.ErrorType.UNKNOWN
        )
        is WebSocketException -> logAndReturnError(
            tag = "WebSocketError",
            message = "WebSocket error: ${e.message}",
            userMessage = "Lost connection to the server. Please check your internet and try again.",
            exception = e,
            errorType = ResponseResult.ErrorType.NETWORK
        )
        else -> logAndReturnError(
            tag = "UnhandledException",
            message = "Unhandled exception: ${e::class.simpleName} - ${e.message}",
            userMessage = "Something unexpected happened. Please try again or contact support.",
            exception = e,
            errorType = ResponseResult.ErrorType.UNKNOWN
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The exception map covers the full landscape of what Ktor can throw. Four categories:

Serialization failures (SerializationException, JsonConvertException, MissingFieldException) — the response body can't be parsed. Maybe the API changed a field type, or a required field is missing. SERIALIZATION signals to the UI that an app update might be needed.

Network failures (IOException, UnresolvedAddressException, WebSocketException) — the request never reached the server. The user is probably offline or on a flaky connection.

Timeout failures (SocketTimeoutException, ConnectTimeoutException, HttpRequestTimeoutException) — the server is reachable but slow. Prompt the user to try again.

HTTP-level exceptions (ClientRequestException, ServerResponseException) — Ktor throws these on 4xx/5xx when expectSuccess is enabled. With our handleResponse approach we handle status codes ourselves, but catching these as a fallback costs nothing.

CancellationException note: We deliberately catch it here rather than re-throwing it. In strict coroutine convention, CancellationException should be re-thrown. In our case the function returns a value rather than throwing — the call site sees an error result and moves on, while the coroutine itself is already being cancelled externally. The behaviour is correct. Be aware of this if you're adapting this code.


logAndReturnError — Structured Logging at the Boundary

inline fun <reified T> logAndReturnError(
    tag: String,
    message: String,
    userMessage: String,
    statusCode: Int? = null,
    serverMessage: String? = null,
    exception: Exception? = null,
    errorType: ResponseResult.ErrorType
): ResponseResult<T> {
    Logger.error(
        message = """
        [$tag] $message
        │ Exception: ${exception?.let { it::class.simpleName } ?: "N/A"}
        │ Message:   ${exception?.message ?: "No details"}
        │ Stacktrace: ${exception?.stackTraceToString() ?: "No stacktrace"}
        """.trimIndent()
    )

    return ResponseResult.error(
        message = userMessage,
        serverMessage = serverMessage,
        code = statusCode,
        errorType = errorType
    )
}
Enter fullscreen mode Exit fullscreen mode

Every error path funnels through this one function. It logs the technical details — tag, internal message, exception class, full stacktrace — and constructs a ResponseResult.Error with a user-facing message. The separation is intentional: the log has everything a developer needs to diagnose the problem; the ResponseResult has what the UI needs to communicate it.


What a Repository Looks Like With This in Place

Here's a complete repository implementation. No try-catch, no error handling, no loading state:

class HomeRepositoryImpl(
    private val networkClient: HttpClient
) : HomeRepository {

    override suspend fun fetchBanners(): ResponseResult<List<Banner>> {
        val response: ResponseResult<List<BannerDto>> =
            networkClient.safeRequest {
                url("some/endpoint/")
                method = HttpMethod.Get
            }
        return response.map { it.toDomain() }
    }

    override suspend fun fetchCategoryItems(): ResponseResult<CategoryWithItems> {
        val response = networkClient.safeRequest<List<CategoryItemDto>> {
            url("some/endpoint/")
            method = HttpMethod.Get
        }
        return response.map { it.toDomain() }
    }

    override suspend fun submitFilterSelection(
        selectedItems: List<CategoryItem>
    ): ResponseResult<CategoryWithItems> {
        val response = networkClient.safeRequest<List<CategoryItemDto>> {
            url("some/endpoint/")
            setBody(selectedItems.toPayload())
            method = HttpMethod.Post
        }
        return response.map { it.toDomain() }
    }
}
Enter fullscreen mode Exit fullscreen mode

Each function does three things: configure the request, make the call, map the result. The entire error surface — network down, server error, serialization failure, auth expiry — is handled by safeRequest and handleResponse, invisible to the repository. The repository's only job is to know the URL, the HTTP method, and how to map the DTO to a domain type.

This also makes repositories easy to test. You mock the HttpClient or the repository interface directly — no exception-throwing behaviour to account for.


A Note on the DefaultResponseDto Envelope

Many APIs wrap their success responses in an envelope too, not just errors. If your backend returns:

{
  "data": { ... },
  "message": "Success",
  "isVerified": true
}
Enter fullscreen mode Exit fullscreen mode

Then your response type would be DefaultResponseDto<YourActualDataDto> and you'd map the inner data directly:

val response = networkClient.safeRequest<DefaultResponseDto<YourDataDto>> {
    url("your/endpoint/")
    method = HttpMethod.Get
}
return response.map { it.data?.toDomain() ?: throw IllegalStateException("Missing data") }
Enter fullscreen mode Exit fullscreen mode

Or more cleanly with flatMap:

return response.flatMap { envelope ->
    envelope.data?.let { ResponseResult.success(it.toDomain()) } ?: ResponseResult.empty()
}
Enter fullscreen mode Exit fullscreen mode

The operators on ResponseResult are designed exactly for this kind of layered unwrapping.


What We've Built

After this part, the networking layer has a complete, consistent error-handling contract:

  • Every repository function returns ResponseResult<T> — success, error, or empty
  • HTTP status codes are mapped to typed ErrorType values the UI can act on
  • Server error messages are extracted and forwarded when available
  • Every exception Ktor can throw is caught and classified
  • Technical errors go to logs; user-facing messages go to the UI
  • Repository code is three lines per function, with zero error handling

What's Next

The ViewModel still needs to react to these results — update loading state, show errors, navigate on success. In Part 3, we'll build the RequestState pattern and the executeNetworkCall / executeNetworkCallWithState functions that make that side of things just as clean. We'll also look at the displayResult Composable extension that lets your UI react to state changes in a single, animated, readable block.


Found this useful? Drop a ❤️ or leave a comment — it helps more developers find the series.

Top comments (0)