Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
9.2 KiB
Network Architecture Decisions
Optional patterns for projects that outgrow the simple approach in networking-ktor.md. Use these when the project needs richer error classification, centralized request handling, or production instrumentation. For auth see networking-ktor-auth.md. For testing see networking-ktor-testing.md.
Error Handling Strategy
Choose one approach and use it consistently across the project.
Decision: Result<T> vs custom sealed class
| Criterion | Result<T> (Kotlin stdlib) |
Custom ApiResult<T> |
|---|---|---|
| Operators | Built-in: map, fold, getOrNull, onSuccess, onFailure |
Define your own |
| Error info | Throwable only — inspect exception type at use site |
Sealed subclasses with structured data per error kind |
| UI branching | when (e) { is IOException -> ... } |
when (error) { is ApiResult.Unauthorized -> ... } |
| Maintenance | Zero — stdlib | Team maintains the sealed class |
| Best for | Most apps, prototypes, APIs with few error-type branches | Apps needing per-error-type UI flows (login redirect, retry prompt, offline message) |
Result<T> is the simpler default. A custom sealed class is justified when the UI needs to branch on many distinct error types and inspecting exception classes becomes unwieldy.
Option A — Kotlin Result<T>
suspend inline fun <reified T> HttpClient.safeRequest(
block: HttpRequestBuilder.() -> Unit,
): Result<T> = runCatching { request { block() }.body<T>() }
// Repository usage
override suspend fun getItems(): Result<List<Item>> {
return client.safeRequest<ItemListDto> { url("items") }
.map { it.items.toDomain() }
}
// ViewModel consumption
viewModelScope.launch {
repository.getItems()
.onSuccess { items -> _state.update { it.copy(items = items) } }
.onFailure { error ->
when (error) {
is ClientRequestException -> handleHttpError(error.response.status.value)
is IOException -> _state.update { it.copy(error = "No connection") }
else -> _state.update { it.copy(error = "Something went wrong") }
}
}
}
Option B — Custom ApiResult<T>
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
sealed class Failure : ApiResult<Nothing>() {
data class HttpError(val code: Int, val message: String?, val serverMessage: String? = null) : Failure()
data class NetworkError(val message: String? = null) : Failure()
data class Timeout(val message: String? = null) : Failure()
data class Unauthorized(val serverMessage: String? = null) : Failure()
data class SerializationError(val message: String? = null) : Failure()
data class Unknown(val throwable: Throwable) : Failure()
}
}
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
is ApiResult.Success -> ApiResult.Success(transform(data))
is ApiResult.Failure -> this
}
inline fun <T, R> ApiResult<T>.fold(
onSuccess: (T) -> R,
onFailure: (ApiResult.Failure) -> R,
): R = when (this) {
is ApiResult.Success -> onSuccess(data)
is ApiResult.Failure -> onFailure(this)
}
fun <T> ApiResult<T>.getOrNull(): T? = (this as? ApiResult.Success)?.data
Safe Request Wrapper
A safeRequest extension centralizes error handling so repositories stay focused on data mapping. This is one valid project-level pattern — not required for every project.
Pair with expectSuccess = false so the wrapper inspects status codes instead of catching Ktor's response exceptions:
suspend inline fun <reified T> HttpClient.safeRequest(
block: HttpRequestBuilder.() -> Unit,
): ApiResult<T> {
return try {
val response = request { block() }
when (response.status.value) {
in 200..299 -> ApiResult.Success(response.body<T>())
else -> classifyStatus(response.status.value, tryParseError(response))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
classifyException(e)
}
}
For 204 No Content responses, use Unit as the type parameter: safeRequest<Unit> { ... }.
Server error message extraction
Parse backend error envelopes safely — never fail if the error body is malformed:
@Serializable
data class ErrorDto(
val message: String? = null,
val error: String? = null,
val detail: String? = null,
) {
val displayMessage: String? get() = message ?: error ?: detail
}
suspend fun tryParseError(response: HttpResponse): String? = runCatching {
response.body<ErrorDto>().displayMessage
}.getOrNull()
Exception Classification
Map Ktor exceptions to error types. Used inside safeRequest with ApiResult, or at the ViewModel level with Result<T>.
fun classifyException(e: Exception): ApiResult.Failure = when (e) {
is HttpRequestTimeoutException,
is ConnectTimeoutException,
is SocketTimeoutException,
-> ApiResult.Failure.Timeout("Request timed out")
is IOException,
is UnresolvedAddressException,
-> ApiResult.Failure.NetworkError("No internet connection")
is SerializationException,
is JsonConvertException,
is MissingFieldException,
-> ApiResult.Failure.SerializationError("Invalid response format")
is ClientRequestException -> when (e.response.status.value) {
401 -> ApiResult.Failure.Unauthorized()
else -> ApiResult.Failure.HttpError(e.response.status.value, "Request failed")
}
is ServerResponseException -> ApiResult.Failure.HttpError(
e.response.status.value, "Server error",
)
else -> ApiResult.Failure.Unknown(e)
}
fun classifyStatus(code: Int, serverMessage: String? = null): ApiResult.Failure = when (code) {
401 -> ApiResult.Failure.Unauthorized(serverMessage)
403 -> ApiResult.Failure.HttpError(code, "Access denied", serverMessage)
404 -> ApiResult.Failure.HttpError(code, "Not found", serverMessage)
429 -> ApiResult.Failure.HttpError(code, "Too many requests", serverMessage)
in 400..599 -> ApiResult.Failure.HttpError(code, if (code < 500) "Request failed" else "Server error", serverMessage)
else -> ApiResult.Failure.HttpError(code, "Unexpected error", serverMessage)
}
CancellationException must always be re-thrown — never swallow it. It breaks structured concurrency.
Plugin Composition
What goes where
| Concern | Where | Why |
|---|---|---|
| Base URL, content type, static headers | defaultRequest {} |
Runs per-request, reads live state |
| JSON parsing | ContentNegotiation |
Core plugin |
| Timeouts | HttpTimeout |
Default for every project |
| Logging | Logging |
Debug aid — sanitize Authorization in production |
| Token load and refresh | Auth plugin |
Built-in retry cycle — see networking-ktor-auth.md |
| Retry on server errors | HttpRequestRetry |
Add when the API has transient failures worth retrying |
| Compression | ContentEncoding |
Add for bandwidth-sensitive APIs |
Plugin install order
Install order matters — plugins execute in installation order for requests, reverse order for responses.
ContentNegotiation → Auth → HttpRequestRetry → HttpTimeout → ContentEncoding
Install HttpRequestRetry before HttpTimeout so retries work on timeout errors. Auth handles 401s independently from HttpRequestRetry — keep these concerns separate.
Custom Client Plugins
Advanced — use when built-in plugins don't cover the need.
Build reusable interceptors with createClientPlugin for analytics, header injection, or response logging:
val ApiKeyPlugin = createClientPlugin("ApiKeyPlugin", ::ApiKeyConfig) {
val apiKey = pluginConfig.apiKey
onRequest { request, _ ->
request.headers.append("X-Api-Key", apiKey)
}
}
class ApiKeyConfig {
var apiKey: String = ""
}
val client = HttpClient(engine) {
install(ApiKeyPlugin) {
apiKey = "my-secret-key"
}
}
For global response observation (analytics, session expiry), use onResponse in a similar plugin without changing error handling.
Debug vs Production Logging
| Concern | Debug | Production |
|---|---|---|
Ktor Logging plugin |
LogLevel.BODY |
LogLevel.HEADERS or not installed |
sanitizeHeader |
Optional | Required for Authorization |
Anti-Patterns
| Anti-pattern | Why it hurts | Better approach |
|---|---|---|
HttpClient per request |
Connection pool waste, resource leaks | Shared singleton via DI |
Swallowing CancellationException |
Breaks structured concurrency, coroutine never cancels | Re-throw explicitly |
| Logging request bodies in production | Leaks sensitive data (tokens, PII) | LogLevel.HEADERS or off; sanitizeHeader for auth |
Mixing expectSuccess = true with manual status inspection |
ClientRequestException thrown before you inspect status |
Pick one: expectSuccess = true + catch exceptions, or false + check response.status |
| Random plugin install order | Retries fire before timeout, auth conflicts with retry | Follow documented composition order |
| Forced specific result wrapper | Doesn't adapt to team conventions or project scale | Present Result/ApiResult as a project decision |