buildKHttpClient
Builds a pre-configured Ktor HttpClient following the Kindling convention.
Features
Base URL prefix on every request
JSON content negotiation (lenient: ignores unknown keys, coerces values)
Connect + request + socket timeouts
Structured error handling: 4xx → KHttpException.ClientError, 5xx → KHttpException.ServerError, unexpected status → KHttpException.NotAccepted
401 differentiation: auth endpoints = wrong credentials, others = expired session
Auth header injection via KAuthProvider (Bearer, JWT, Basic, ApiKey, Custom) — also the correct place for global request headers (trace IDs, app version, etc.)
Automatic token refresh via KTokenRefresher, deduplicated by dev.kindling.utils.SingleFlight
Response-side hooks via KRequestInterceptor (analytics, metrics)
In-memory GET cache via KCacheConfig, backed by dev.kindling.utils.CircularBuffer and dev.kindling.utils.KMap
Optional cookie storage for cookie-based auth
Configurable logging
What is intentionally NOT in this client
Retry logic → use dev.kindling.utils.RetryRunner at the repository layer for per-call retry with observable state (isLoading, attempt, error).
Rate limiting → use dev.kindling.utils.Throttler or dev.kindling.utils.KThrottle at the ViewModel/UseCase layer for per-action throttling.
Request mutation → use KAuthProvider.Custom instead of a separate interceptor.
Plugin installation order (matters for 401 interception)
ContentNegotiation — JSON (de)serialisation
HttpTimeout — connect + request + socket timeouts
Logging — structured request/response logging
KRequestInterceptor — response-side hooks (analytics)
KCache — in-memory GET cache
KAuthProvider — auth header injection (also for global headers)
KTokenRefresh — 401 intercept via Send hook + SingleFlight deduplication
HttpCallValidator — 4xx/5xx → KHttpException (sees final response after retry)
HttpCookies — optional cookie storage
Usage
// Standard Bearer JWT with auto-refresh and response analytics
val client = buildKHttpClient(
KHttpConfig(
baseUrl = "https://api.example.com/",
authProvider = KAuthProvider.Custom { request ->
session.accessToken.value?.let { request.bearerAuth(it) }
request.header("X-App-Version", BuildConfig.VERSION_NAME)
request.header("X-Trace-Id", UUID.randomUUID().toString())
},
tokenRefresher = KDefaultTokenRefresher(
refreshUrl = "https://auth.example.com/refresh",
getRefreshToken = { session.refreshToken.value },
onTokenRefreshed = { access, refresh -> session.saveTokens(access, refresh) },
onFailed = { session.clearSession() },
authProvider = authProvider,
),
interceptor = KRequestInterceptor(
onResponse = { analytics.track(it.status.value) },
),
cacheConfig = KCacheConfig(
maxAgeSeconds = 300,
strategy = KCacheStrategy.NetworkFirst,
),
onSessionExpired = { session.clearSession() },
onClientError = { code, msg -> toast("Error $code: $msg") },
onServerError = { code, msg -> toast("Server $code: $msg") },
)
)
// Repository layer: retry with RetryRunner
class ProductRepository(private val api: ProductAPI) {
private val retryRunner = RetryRunner<List<ProductDto>>(
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
retries = 3,
delay = 500.milliseconds,
backoffFactor = 2.0,
)
suspend fun getProducts() = retryRunner.run { api.getProducts() }
val isLoading = retryRunner.isLoading
}
// ViewModel layer: throttle user actions with Throttler
class SearchViewModel : ViewModel() {
private val throttler = KThrottle<String>(viewModelScope, 500.milliseconds) { query ->
repository.search(query)
}
fun onSearchInput(query: String) = throttler.emit(query)
}