
Application-level networking layer offering typed NetworkResult, structured NetworkError, ordered interceptor pipeline (auth single‑flight refresh, retry, circuit breaker), redacted logging, converters, test doubles.
The application networking layer built on Ktor.
Ktor is an excellent HTTP engine. It handles TCP, TLS, HTTP/1.1, HTTP/2 — the transport layer — very well. What it deliberately does not handle is everything above the transport:
sendPipeline, receivePipeline). It works for transport concerns. It breaks for application concerns like auth, because you cannot control ordering across plugins.invalid_grant. This is the single most common networking bug in KMP apps, and Ktor leaves it to you.Retry-After parsing, the idempotency guard, and the deadline propagation yourself. Most implementations get at least one of these wrong.ClientRequestException tells you something failed. It does not tell you whether it was a DNS failure, a TLS handshake, a 401, a timeout on connect vs. read vs. a logical deadline. Branching on error kind requires exception message parsing.Logging plugin logs what it receives. If Authorization or password is in that payload, it goes to your log sink.CaterKtor solves all of this — on top of Ktor's engines, without replacing them.
Your App
│
▼
NetworkClient (CaterKtor)
├── Auth interceptor ← single-flight 401 refresh, budgeted
├── Retry interceptor ← exponential backoff, jitter, Retry-After
├── Circuit breaker ← fail fast when downstream is broken
├── Logging interceptor ← structured, redacted
└── Transport ← KtorTransport → OkHttp / Darwin / CIO
One object. Explicit ordering. Typed results. Correct concurrency semantics.
# gradle/libs.versions.toml
[versions]
caterktor = "0.2.0"
[libraries]
caterktor-core = { module = "io.github.oyedsamu:caterktor-core", version.ref = "caterktor" }
caterktor-ktor = { module = "io.github.oyedsamu:caterktor-ktor", version.ref = "caterktor" }
caterktor-auth = { module = "io.github.oyedsamu:caterktor-auth", version.ref = "caterktor" }
caterktor-logging = { module = "io.github.oyedsamu:caterktor-logging", version.ref = "caterktor" }
caterktor-serialization-json = { module = "io.github.oyedsamu:caterktor-serialization-json", version.ref = "caterktor" }
caterktor-engine-okhttp = { module = "io.github.oyedsamu:caterktor-engine-okhttp", version.ref = "caterktor" }
caterktor-engine-darwin = { module = "io.github.oyedsamu:caterktor-engine-darwin", version.ref = "caterktor" }
caterktor-engine-cio = { module = "io.github.oyedsamu:caterktor-engine-cio", version.ref = "caterktor" }
caterktor-connectivity = { module = "io.github.oyedsamu:caterktor-connectivity", version.ref = "caterktor" }
caterktor-websocket = { module = "io.github.oyedsamu:caterktor-websocket", version.ref = "caterktor" }
caterktor-sse = { module = "io.github.oyedsamu:caterktor-sse", version.ref = "caterktor" }
caterktor-testing = { module = "io.github.oyedsamu:caterktor-testing", version.ref = "caterktor" }// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.caterktor.core)
implementation(libs.caterktor.ktor)
implementation(libs.caterktor.auth)
implementation(libs.caterktor.logging)
implementation(libs.caterktor.serialization.json)
}
androidMain.dependencies {
implementation(libs.caterktor.engine.okhttp)
}
iosMain.dependencies {
implementation(libs.caterktor.engine.darwin)
}
jvmMain.dependencies {
implementation(libs.caterktor.engine.cio)
}
commonTest.dependencies {
implementation(libs.caterktor.testing)
}
}
}// app/build.gradle.kts
dependencies {
implementation("io.github.oyedsamu:caterktor-core:0.2.0")
implementation("io.github.oyedsamu:caterktor-ktor:0.2.0")
implementation("io.github.oyedsamu:caterktor-engine-okhttp:0.2.0")
implementation("io.github.oyedsamu:caterktor-auth:0.2.0")
implementation("io.github.oyedsamu:caterktor-serialization-json:0.2.0")
implementation("io.github.oyedsamu:caterktor-logging:0.2.0")
implementation("io.github.oyedsamu:caterktor-connectivity:0.2.0")
testImplementation("io.github.oyedsamu:caterktor-testing:0.2.0")
}dependencies {
implementation("io.github.oyedsamu:caterktor-core:0.2.0")
implementation("io.github.oyedsamu:caterktor-ktor:0.2.0")
implementation("io.github.oyedsamu:caterktor-engine-cio:0.2.0")
implementation("io.github.oyedsamu:caterktor-serialization-json:0.2.0")
implementation("io.github.oyedsamu:caterktor-websocket:0.2.0")
implementation("io.github.oyedsamu:caterktor-sse:0.2.0")
testImplementation("io.github.oyedsamu:caterktor-testing:0.2.0")
}A CI-compiled, runnable version of this exact sample lives in
caterktor-sample/. Run it locally:./gradlew :caterktor-sample:jvmRun. The sample also prints source-backed upload and streaming download progress events.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
baseUrl = "https://api.example.com"
addConverter(KotlinxJsonConverter())
auth {
bearer { tokenProvider { tokenStore.accessToken() } }
refresh { refreshToken { tokenStore.refreshAccessToken() } }
}
addInterceptor(RetryInterceptor(maxAttempts = 3))
addInterceptor(LoggerInterceptor(level = LogLevel.Headers) { line -> println(line) })
}
val result: NetworkResult<User> = client.get("/users/me")
when (result) {
is NetworkResult.Success -> showProfile(result.body)
is NetworkResult.Failure -> when (val error = result.error) {
is NetworkError.Http -> if (error.status == HttpStatus.Unauthorized) reLogin()
is NetworkError.Timeout -> showRetryPrompt()
is NetworkError.ConnectionFailed -> showOfflineBanner()
else -> reportUnexpected(error)
}
}Typed helpers also build query strings without manual concatenation:
val page: NetworkResult<PokemonResponse> = client.get(
url = "pokemon",
queryParams = QueryParameters {
add("limit", 20)
add("offset", 40)
add("type", "electric")
add("type", "flying")
},
)queryParameters(mapOf("limit" to 20, "offset" to 40)) is available when a
map is the more natural shape. null values are omitted, repeated names are
preserved, and names/values are percent-encoded.
Every call returns a sealed NetworkResult<T>. There is no third state, no thrown exception to
catch, no null to guard.
sealed interface NetworkResult<out T> {
data class Success<T>(
val body: T,
val status: HttpStatus,
val headers: Headers,
val durationMs: Long, // wall-clock ms, includes all retries and refresh waits
val attempts: Int, // 1 = first attempt succeeded
val requestId: String, // log correlation ID
) : NetworkResult<T>
data class Failure(
val error: NetworkError,
val durationMs: Long,
val attempts: Int,
val requestId: String,
) : NetworkResult<Nothing>
}Extension functions cover the common patterns without unwrapping manually:
val user = result.getOrThrow() // throws on Failure
val user = result.getOrDefault(User.ANONYMOUS) // fallback on Failure
result.onSuccess { user -> render(user) }
.onFailure { error -> log(error) }
val mapped = result.map { user -> UserUiModel(user) }sealed interface NetworkError {
data class Http(val status: HttpStatus, val headers: Headers, val body: ErrorBody) : NetworkError
data class ConnectionFailed(val kind: ConnectionFailureKind) : NetworkError // Dns, Refused, Unreachable, TlsHandshake
data class Timeout(val kind: TimeoutKind) : NetworkError // Connect, Socket, Request, Deadline
data class Serialization(val phase: SerializationPhase, val rawBody: RawBody?) : NetworkError
data class Protocol(val message: String) : NetworkError
data class CircuitOpen(val name: String, val state: CircuitBreakerState) : NetworkError
data class Unknown(override val cause: Throwable) : NetworkError
}CancellationException is never wrapped as a Failure. It propagates directly through the
pipeline to the calling coroutine. If coroutine cancellation can reach your call site, let it.
Interceptors run in the order they are added. The terminal transport is always last.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addInterceptor(DefaultHeadersInterceptor("User-Agent" to "MyApp/1.0"))
addInterceptor(AuthRefreshInterceptor(...)) // auth before retry — intentional
addInterceptor(RetryInterceptor())
addInterceptor(LoggerInterceptor(LogLevel.Body) { println(it) })
}
// Print the exact execution order at any time:
println(client.describePipeline())
// [0] DefaultHeadersInterceptor
// [1] AuthRefreshInterceptor
// [2] RetryInterceptor
// [3] LoggerInterceptor
// [4] Transport(KtorTransport)Ordering matters. Auth before retry means a refreshed token is used on the retry attempt. Logging after retry means you see exactly what was sent on each attempt, not just the first.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
auth {
bearer { tokenProvider { tokenStore.accessToken() } }
}
}When ten concurrent requests all receive a 401, exactly one refresh call is made. The other
nine suspend on the same Deferred<Token> and resume with the refreshed token without racing.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
auth {
bearer {
tokenProvider { tokenStore.accessToken() }
}
refresh {
refreshToken {
// Called at most once per budget window, regardless of concurrency
tokenStore.refreshAccessToken()
}
budget(maxRefreshes = 1, windowMs = 60_000L) // default: 1 refresh per 60 s
onRefreshFailed { cause ->
// Fires exactly once when the budget is exhausted or refresh throws
navigator.navigateToLogin()
}
}
}
}To call auth endpoints through the same client without triggering the auth interceptor:
client.post<Token>(
url = "auth/refresh",
attributes = Attributes { put(CaterKtorKeys.SKIP_AUTH, true) },
)@OptIn(ExperimentalCaterktor::class)
addInterceptor(
RetryInterceptor(
maxAttempts = 3,
policy = ExponentialBackoffPolicy(
baseDelayMs = 200L,
maxDelayMs = 10_000L,
jitterFactor = 1.0, // full jitter — default and recommended
),
retryNonIdempotent = false, // set true + add Idempotency-Key for POST/PATCH
)
)Default behaviour out of the box:
NetworkError.Timeout, NetworkError.ConnectionFailed, and HTTP 502 / 503 / 504[0, cap], preventing thundering herdsRetry-After headers override the computed delayretryNonIdempotent = true and an Idempotency-Key request header — missing the header with opt-in enabled throws immediately rather than silently skipping@OptIn(ExperimentalCaterktor::class)
addInterceptor(
CircuitBreaker(
failureThreshold = 5,
openDurationMs = 30_000L,
)
)After failureThreshold consecutive failures the circuit opens and rejects all calls with
NetworkError.CircuitOpen until the open window expires. The next probe attempt moves it to
HalfOpen. A successful probe closes it; a failed probe reopens it. State transitions surface
as NetworkEvent.CircuitBreakerTransition.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addConverter(KotlinxJsonConverter()) // lenient defaults
// or with custom Json instance:
addConverter(KotlinxJsonConverter(Json { ignoreUnknownKeys = true }))
}
@Serializable data class User(val id: String, val name: String)
val result: NetworkResult<User> = client.get("/users/me")@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
contentNegotiation {
register("application/json", KotlinxJsonConverter(), quality = 1.0)
register("application/x-protobuf", KotlinxProtobufConverter(), quality = 0.9)
register("application/cbor", KotlinxCborConverter(), quality = 0.8)
}
}The Accept header is constructed automatically from registered converters and their quality
values. The response Content-Type drives decode dispatch — no branching in app code.
Many APIs wrap responses in an outer envelope. Unwrap it before decode:
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addConverter(KotlinxJsonConverter())
responseUnwrapper = DataFieldUnwrapper("data") // unwraps {"data": {...}}
}Built-in unwrappers: DataFieldUnwrapper, PagedUnwrapper. Implement ResponseUnwrapper for
custom shapes. Override per-request via NetworkRequest.attributes.
@OptIn(ExperimentalCaterktor::class)
addInterceptor(
LoggerInterceptor(
level = LogLevel.Body,
logger = { line -> Napier.d(line) }, // wire any logger
redaction = RedactionEngine(
headerNames = setOf("Authorization", "X-Session-Token"),
queryParams = setOf("api_key", "token"),
jsonBodyFields = setOf("password", "ssn", "cardNumber"),
),
)
)Redaction is on by default when logging is enabled. The default RedactionEngine redacts
Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Auth-Token, and X-Api-Key
headers automatically. Add fields; you cannot globally disable. Transport failures (DNS, TLS, timeout)
are logged as <! ErrorType after Xms: message on the failure path, not only on successful responses.
Log levels:
| Level | What you see |
|---|---|
None |
Nothing |
Basic |
Method, URL, status, duration |
Headers |
Above + request and response headers (redacted) |
Body |
Above + request and response bodies (redacted) |
CaterKtor ships a dedicated testing artifact so your repository tests never touch a real network.
@OptIn(ExperimentalCaterktor::class)
class UserRepositoryTest {
@Test
fun returnsUserOnSuccess() = runTest {
val fake = FakeNetworkClient {
addConverter(KotlinxJsonConverter())
}
fake.enqueue(jsonResponse("""{"id":"1","name":"Ada"}"""))
val repo = UserRepository(fake.client)
val user = repo.getUser("1")
assertEquals("Ada", user.name)
assertEquals(1, fake.requests.size)
}
@Test
fun handlesUnauthorized() = runTest {
val fake = FakeNetworkClient()
fake.enqueue(testResponse(status = HttpStatus.Unauthorized))
val repo = UserRepository(fake.client)
assertIs<NetworkResult.Failure>(repo.getUser("1"))
}
}@OptIn(ExperimentalCaterktor::class)
class UserApiTest {
@Test
fun fetchesUserFromRoute() = runTest {
val server = CaterktorTestServer()
server.route(HttpMethod.GET, "/users/me", jsonResponse("""{"id":"1","name":"Ada"}"""))
val client = server.client { addConverter(KotlinxJsonConverter()) }
val result = client.get<User>("/users/me")
assertIs<NetworkResult.Success<User>>(result)
assertEquals("Ada", result.body.name)
}
}Rules can also match path templates and expose path parameters:
val fake = FakeTransport {
get("/users/{id}") { match ->
jsonResponse("""{"id":"${match.pathParameters["id"]}"}""")
}
}Test helpers at a glance:
testResponse(status = HttpStatus.OK, body = byteArrayOf()) // blank 200
jsonResponse("""{"key":"value"}""") // 200 + Content-Type: application/json
httpFailure(HttpStatus.NotFound) // NetworkError.Http pre-builtNetworkClient.events is a SharedFlow<NetworkEvent>. Collect it to wire any observability
backend — structured logs, metrics, traces — without coupling to the interceptor chain.
@OptIn(ExperimentalCaterktor::class)
scope.launch {
client.events.collect { event ->
when (event) {
is NetworkEvent.CallStart -> metrics.requestStarted(event.requestId)
is NetworkEvent.CallSuccess -> metrics.recordLatency(event.requestId, event.durationMs)
is NetworkEvent.CallFailure -> metrics.recordError(event.requestId, event.error)
is NetworkEvent.UploadProgress ->
metrics.uploaded(event.requestId, event.bytesSent, event.totalBytes)
is NetworkEvent.DownloadProgress ->
metrics.downloaded(event.requestId, event.bytesRead, event.totalBytes)
is NetworkEvent.ResponseReceived -> {}
is NetworkEvent.CircuitBreakerTransition ->
log.warn("Circuit ${event.name}: ${event.from} → ${event.to}")
}
}
}Progress totals are nullable because chunked requests, generated streams, and some server
responses do not know their final length up front. Treat null as indeterminate progress:
fun percent(done: Long, total: Long?): Int? =
total?.takeIf { it > 0L }?.let { ((done * 100) / it).toInt() }caterktor-logging also includes an event-derived logger for the same flow:
val eventLogger = NetworkEventLogger { line -> println(line) }
scope.launch {
client.events.collect(eventLogger::log)
}requestId is consistent across all events for the same logical call — start, retries, refresh
waits, and final outcome all share one ID, making log correlation across interceptors exact.
For large responses, use the Ktor-backed block-scoped streaming API. The body source is one-shot and must be consumed inside the block; Ktor releases the underlying response resources when the block returns.
val bytesWritten = transport.download(
NetworkRequest(HttpMethod.GET, "https://cdn.example.com/archive.zip"),
requestId = "archive-download",
onDownloadProgress = { progress ->
val label = progress.totalBytes?.let { "${progress.bytesRead}/$it" }
?: "${progress.bytesRead} bytes"
println("download ${progress.requestId}: $label")
},
) { response ->
val source = response.body.source()
try {
source.transferTo(fileSink)
} finally {
source.close()
}
}If you are already using NetworkClient.events, a streaming ResponseBody.Source returned by a
custom transport also emits NetworkEvent.DownloadProgress as the source is consumed. The
Ktor-specific callback above exists for the lower-level KtorTransport.download(...) escape hatch,
which runs outside the NetworkClient.events flow.
Typed helpers such as client.get<T>() still buffer up to maxBodyDecodeBytes before decoding,
which is the right behavior for JSON/protobuf models. Use KtorTransport.download(...) for file
or blob downloads.
Use RequestBody.Form for application/x-www-form-urlencoded requests:
val body = RequestBody.Form(
RequestBody.Form.Field("grant_type", "refresh_token"),
RequestBody.Form.Field("refresh_token", refreshToken),
)Use RequestBody.Multipart for form-data uploads:
val body = RequestBody.Multipart(
RequestBody.Multipart.Part.field("title", "avatar"),
RequestBody.Multipart.Part.formData(
name = "file",
filename = "avatar.png",
body = RequestBody.Source(
sourceFactory = { imageSource() },
contentType = "image/png",
),
),
)Source-backed multipart parts stream through KtorTransport without first materializing the file
body. When the request goes through NetworkClient, source-backed parts also emit
NetworkEvent.UploadProgress:
val uploadEvents = scope.launch {
client.events.collect { event ->
if (event is NetworkEvent.UploadProgress) {
println("uploaded ${event.bytesSent} of ${event.totalBytes ?: "unknown"}")
}
}
}
client.execute(
NetworkRequest(
method = HttpMethod.POST,
url = "https://api.example.com/profile/avatar",
body = body,
),
)Use contentLength when you know it. That gives progress UIs a determinate total; omit it for
chunked or generated streams:
RequestBody.Source(
sourceFactory = { fileSystem.source(path) },
contentType = "application/octet-stream",
contentLength = fileSize,
)@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
timeout {
requestTimeoutMs = 30_000L // per attempt
}
}Per-call deadlines are passed to typed helpers through the deadline parameter and propagate
through Chain, so retry delays and auth refresh waits can honor the same logical budget.
Always handle both variants. NetworkResult is sealed. The compiler will warn on a non-exhaustive
when. Do not suppress the warning — handle the Failure branch.
Do not catch CancellationException. CaterKtor never wraps it. If a coroutine is cancelled
mid-request, the exception propagates to the scope that owns the coroutine. Catching it breaks
structured concurrency.
Keep the NetworkClient as a singleton. Constructing a new client per request creates a new
Ktor HttpClient and its underlying engine thread pool. One client per logical backend (API, CDN,
internal service) is the right granularity.
Use describePipeline() when debugging ordering issues. The output is the contract — the list
matches the exact execution order at runtime.
Scope @OptIn(ExperimentalCaterktor::class) to the file, not the module. This makes it easy
to find and update call sites when surfaces stabilise.
CaterKtor is moving from 0.2.0 into 0.3.0. The next release is a maturity
release: progress telemetry lands, while adapter/platform candidates ship only
when their release gates are proven locally.
0.2.0 expanded the transport,
testing, observability, platform, and realtime surfaces while keeping the core
pipeline BCV-gated.
KtorTransport.download(request) { response -> ... }
RequestBody.Multipart and RequestBody.Form for file upload and form submission/users/{id}
CaterktorHttpServer for real TCP integration testsNetworkEventLogger
ConnectivityProbe support via caterktor-connectivity
caterktor-websocket
caterktor-sse
NetworkEvent
KtorTransport.download(...) progress callback for lower-level streaming downloadswasmJsTest and publication gates@ExperimentalCaterktor audit before any selective API graduationInterceptor and Chain graduate out of @ExperimentalCaterktor
These are out of scope and will not be added:
BodyConverter implementationThese are honest limitations of the current release, not bugs that slipped through:
Regular execute() and typed helpers still buffer responses. Use
KtorTransport.download(request) { ... } for large file/blob downloads. Typed decoding remains
bounded by maxBodyDecodeBytes.
Progress events are 0.3.0 scope. They are additive to lifecycle events and report nullable totals because many streaming bodies do not know their length up front.
CaterktorTestServer is still in-memory by design. Use JVM-only
CaterktorHttpServer when tests need a real TCP socket and HTTP framing semantics.
@ExperimentalCaterktor is required on most public API surfaces. Graduation is intentionally
conservative; APIs stay experimental when adapter/platform work may still reshape them.
OTel and Ktorfit adapters are not yet released. These modules remain reserved until local all-target release gates prove they can share CaterKtor's runtime semantics.
Contributions are welcome. Before opening a pull request:
Open an issue first for any non-trivial change — a quick alignment on direction saves wasted effort on both sides.
Run the full verification gate locally:
./gradlew check apiCheckAll tests must pass on all enabled targets.
Public API changes require an API dump update:
./gradlew apiDumpCommit the updated .api files alongside the code change.
Match the existing code style. The codebase uses explicitApi(), KDoc on every public
symbol, and @ExperimentalCaterktor on surfaces that may change.
Tests are not optional. New interceptors need unit tests against FakeNetworkClient.
New transport behaviour needs tests against CaterktorTestServer.
See CONTRIBUTING.md for the full guide, and CODE_OF_CONDUCT.md for community standards. Security issues should follow the process in SECURITY.md.
Copyright 2024 Samuel Oyedele
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The application networking layer built on Ktor.
Ktor is an excellent HTTP engine. It handles TCP, TLS, HTTP/1.1, HTTP/2 — the transport layer — very well. What it deliberately does not handle is everything above the transport:
sendPipeline, receivePipeline). It works for transport concerns. It breaks for application concerns like auth, because you cannot control ordering across plugins.invalid_grant. This is the single most common networking bug in KMP apps, and Ktor leaves it to you.Retry-After parsing, the idempotency guard, and the deadline propagation yourself. Most implementations get at least one of these wrong.ClientRequestException tells you something failed. It does not tell you whether it was a DNS failure, a TLS handshake, a 401, a timeout on connect vs. read vs. a logical deadline. Branching on error kind requires exception message parsing.Logging plugin logs what it receives. If Authorization or password is in that payload, it goes to your log sink.CaterKtor solves all of this — on top of Ktor's engines, without replacing them.
Your App
│
▼
NetworkClient (CaterKtor)
├── Auth interceptor ← single-flight 401 refresh, budgeted
├── Retry interceptor ← exponential backoff, jitter, Retry-After
├── Circuit breaker ← fail fast when downstream is broken
├── Logging interceptor ← structured, redacted
└── Transport ← KtorTransport → OkHttp / Darwin / CIO
One object. Explicit ordering. Typed results. Correct concurrency semantics.
# gradle/libs.versions.toml
[versions]
caterktor = "0.2.0"
[libraries]
caterktor-core = { module = "io.github.oyedsamu:caterktor-core", version.ref = "caterktor" }
caterktor-ktor = { module = "io.github.oyedsamu:caterktor-ktor", version.ref = "caterktor" }
caterktor-auth = { module = "io.github.oyedsamu:caterktor-auth", version.ref = "caterktor" }
caterktor-logging = { module = "io.github.oyedsamu:caterktor-logging", version.ref = "caterktor" }
caterktor-serialization-json = { module = "io.github.oyedsamu:caterktor-serialization-json", version.ref = "caterktor" }
caterktor-engine-okhttp = { module = "io.github.oyedsamu:caterktor-engine-okhttp", version.ref = "caterktor" }
caterktor-engine-darwin = { module = "io.github.oyedsamu:caterktor-engine-darwin", version.ref = "caterktor" }
caterktor-engine-cio = { module = "io.github.oyedsamu:caterktor-engine-cio", version.ref = "caterktor" }
caterktor-connectivity = { module = "io.github.oyedsamu:caterktor-connectivity", version.ref = "caterktor" }
caterktor-websocket = { module = "io.github.oyedsamu:caterktor-websocket", version.ref = "caterktor" }
caterktor-sse = { module = "io.github.oyedsamu:caterktor-sse", version.ref = "caterktor" }
caterktor-testing = { module = "io.github.oyedsamu:caterktor-testing", version.ref = "caterktor" }// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.caterktor.core)
implementation(libs.caterktor.ktor)
implementation(libs.caterktor.auth)
implementation(libs.caterktor.logging)
implementation(libs.caterktor.serialization.json)
}
androidMain.dependencies {
implementation(libs.caterktor.engine.okhttp)
}
iosMain.dependencies {
implementation(libs.caterktor.engine.darwin)
}
jvmMain.dependencies {
implementation(libs.caterktor.engine.cio)
}
commonTest.dependencies {
implementation(libs.caterktor.testing)
}
}
}// app/build.gradle.kts
dependencies {
implementation("io.github.oyedsamu:caterktor-core:0.2.0")
implementation("io.github.oyedsamu:caterktor-ktor:0.2.0")
implementation("io.github.oyedsamu:caterktor-engine-okhttp:0.2.0")
implementation("io.github.oyedsamu:caterktor-auth:0.2.0")
implementation("io.github.oyedsamu:caterktor-serialization-json:0.2.0")
implementation("io.github.oyedsamu:caterktor-logging:0.2.0")
implementation("io.github.oyedsamu:caterktor-connectivity:0.2.0")
testImplementation("io.github.oyedsamu:caterktor-testing:0.2.0")
}dependencies {
implementation("io.github.oyedsamu:caterktor-core:0.2.0")
implementation("io.github.oyedsamu:caterktor-ktor:0.2.0")
implementation("io.github.oyedsamu:caterktor-engine-cio:0.2.0")
implementation("io.github.oyedsamu:caterktor-serialization-json:0.2.0")
implementation("io.github.oyedsamu:caterktor-websocket:0.2.0")
implementation("io.github.oyedsamu:caterktor-sse:0.2.0")
testImplementation("io.github.oyedsamu:caterktor-testing:0.2.0")
}A CI-compiled, runnable version of this exact sample lives in
caterktor-sample/. Run it locally:./gradlew :caterktor-sample:jvmRun. The sample also prints source-backed upload and streaming download progress events.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
baseUrl = "https://api.example.com"
addConverter(KotlinxJsonConverter())
auth {
bearer { tokenProvider { tokenStore.accessToken() } }
refresh { refreshToken { tokenStore.refreshAccessToken() } }
}
addInterceptor(RetryInterceptor(maxAttempts = 3))
addInterceptor(LoggerInterceptor(level = LogLevel.Headers) { line -> println(line) })
}
val result: NetworkResult<User> = client.get("/users/me")
when (result) {
is NetworkResult.Success -> showProfile(result.body)
is NetworkResult.Failure -> when (val error = result.error) {
is NetworkError.Http -> if (error.status == HttpStatus.Unauthorized) reLogin()
is NetworkError.Timeout -> showRetryPrompt()
is NetworkError.ConnectionFailed -> showOfflineBanner()
else -> reportUnexpected(error)
}
}Typed helpers also build query strings without manual concatenation:
val page: NetworkResult<PokemonResponse> = client.get(
url = "pokemon",
queryParams = QueryParameters {
add("limit", 20)
add("offset", 40)
add("type", "electric")
add("type", "flying")
},
)queryParameters(mapOf("limit" to 20, "offset" to 40)) is available when a
map is the more natural shape. null values are omitted, repeated names are
preserved, and names/values are percent-encoded.
Every call returns a sealed NetworkResult<T>. There is no third state, no thrown exception to
catch, no null to guard.
sealed interface NetworkResult<out T> {
data class Success<T>(
val body: T,
val status: HttpStatus,
val headers: Headers,
val durationMs: Long, // wall-clock ms, includes all retries and refresh waits
val attempts: Int, // 1 = first attempt succeeded
val requestId: String, // log correlation ID
) : NetworkResult<T>
data class Failure(
val error: NetworkError,
val durationMs: Long,
val attempts: Int,
val requestId: String,
) : NetworkResult<Nothing>
}Extension functions cover the common patterns without unwrapping manually:
val user = result.getOrThrow() // throws on Failure
val user = result.getOrDefault(User.ANONYMOUS) // fallback on Failure
result.onSuccess { user -> render(user) }
.onFailure { error -> log(error) }
val mapped = result.map { user -> UserUiModel(user) }sealed interface NetworkError {
data class Http(val status: HttpStatus, val headers: Headers, val body: ErrorBody) : NetworkError
data class ConnectionFailed(val kind: ConnectionFailureKind) : NetworkError // Dns, Refused, Unreachable, TlsHandshake
data class Timeout(val kind: TimeoutKind) : NetworkError // Connect, Socket, Request, Deadline
data class Serialization(val phase: SerializationPhase, val rawBody: RawBody?) : NetworkError
data class Protocol(val message: String) : NetworkError
data class CircuitOpen(val name: String, val state: CircuitBreakerState) : NetworkError
data class Unknown(override val cause: Throwable) : NetworkError
}CancellationException is never wrapped as a Failure. It propagates directly through the
pipeline to the calling coroutine. If coroutine cancellation can reach your call site, let it.
Interceptors run in the order they are added. The terminal transport is always last.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addInterceptor(DefaultHeadersInterceptor("User-Agent" to "MyApp/1.0"))
addInterceptor(AuthRefreshInterceptor(...)) // auth before retry — intentional
addInterceptor(RetryInterceptor())
addInterceptor(LoggerInterceptor(LogLevel.Body) { println(it) })
}
// Print the exact execution order at any time:
println(client.describePipeline())
// [0] DefaultHeadersInterceptor
// [1] AuthRefreshInterceptor
// [2] RetryInterceptor
// [3] LoggerInterceptor
// [4] Transport(KtorTransport)Ordering matters. Auth before retry means a refreshed token is used on the retry attempt. Logging after retry means you see exactly what was sent on each attempt, not just the first.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
auth {
bearer { tokenProvider { tokenStore.accessToken() } }
}
}When ten concurrent requests all receive a 401, exactly one refresh call is made. The other
nine suspend on the same Deferred<Token> and resume with the refreshed token without racing.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
auth {
bearer {
tokenProvider { tokenStore.accessToken() }
}
refresh {
refreshToken {
// Called at most once per budget window, regardless of concurrency
tokenStore.refreshAccessToken()
}
budget(maxRefreshes = 1, windowMs = 60_000L) // default: 1 refresh per 60 s
onRefreshFailed { cause ->
// Fires exactly once when the budget is exhausted or refresh throws
navigator.navigateToLogin()
}
}
}
}To call auth endpoints through the same client without triggering the auth interceptor:
client.post<Token>(
url = "auth/refresh",
attributes = Attributes { put(CaterKtorKeys.SKIP_AUTH, true) },
)@OptIn(ExperimentalCaterktor::class)
addInterceptor(
RetryInterceptor(
maxAttempts = 3,
policy = ExponentialBackoffPolicy(
baseDelayMs = 200L,
maxDelayMs = 10_000L,
jitterFactor = 1.0, // full jitter — default and recommended
),
retryNonIdempotent = false, // set true + add Idempotency-Key for POST/PATCH
)
)Default behaviour out of the box:
NetworkError.Timeout, NetworkError.ConnectionFailed, and HTTP 502 / 503 / 504[0, cap], preventing thundering herdsRetry-After headers override the computed delayretryNonIdempotent = true and an Idempotency-Key request header — missing the header with opt-in enabled throws immediately rather than silently skipping@OptIn(ExperimentalCaterktor::class)
addInterceptor(
CircuitBreaker(
failureThreshold = 5,
openDurationMs = 30_000L,
)
)After failureThreshold consecutive failures the circuit opens and rejects all calls with
NetworkError.CircuitOpen until the open window expires. The next probe attempt moves it to
HalfOpen. A successful probe closes it; a failed probe reopens it. State transitions surface
as NetworkEvent.CircuitBreakerTransition.
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addConverter(KotlinxJsonConverter()) // lenient defaults
// or with custom Json instance:
addConverter(KotlinxJsonConverter(Json { ignoreUnknownKeys = true }))
}
@Serializable data class User(val id: String, val name: String)
val result: NetworkResult<User> = client.get("/users/me")@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
contentNegotiation {
register("application/json", KotlinxJsonConverter(), quality = 1.0)
register("application/x-protobuf", KotlinxProtobufConverter(), quality = 0.9)
register("application/cbor", KotlinxCborConverter(), quality = 0.8)
}
}The Accept header is constructed automatically from registered converters and their quality
values. The response Content-Type drives decode dispatch — no branching in app code.
Many APIs wrap responses in an outer envelope. Unwrap it before decode:
@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
addConverter(KotlinxJsonConverter())
responseUnwrapper = DataFieldUnwrapper("data") // unwraps {"data": {...}}
}Built-in unwrappers: DataFieldUnwrapper, PagedUnwrapper. Implement ResponseUnwrapper for
custom shapes. Override per-request via NetworkRequest.attributes.
@OptIn(ExperimentalCaterktor::class)
addInterceptor(
LoggerInterceptor(
level = LogLevel.Body,
logger = { line -> Napier.d(line) }, // wire any logger
redaction = RedactionEngine(
headerNames = setOf("Authorization", "X-Session-Token"),
queryParams = setOf("api_key", "token"),
jsonBodyFields = setOf("password", "ssn", "cardNumber"),
),
)
)Redaction is on by default when logging is enabled. The default RedactionEngine redacts
Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Auth-Token, and X-Api-Key
headers automatically. Add fields; you cannot globally disable. Transport failures (DNS, TLS, timeout)
are logged as <! ErrorType after Xms: message on the failure path, not only on successful responses.
Log levels:
| Level | What you see |
|---|---|
None |
Nothing |
Basic |
Method, URL, status, duration |
Headers |
Above + request and response headers (redacted) |
Body |
Above + request and response bodies (redacted) |
CaterKtor ships a dedicated testing artifact so your repository tests never touch a real network.
@OptIn(ExperimentalCaterktor::class)
class UserRepositoryTest {
@Test
fun returnsUserOnSuccess() = runTest {
val fake = FakeNetworkClient {
addConverter(KotlinxJsonConverter())
}
fake.enqueue(jsonResponse("""{"id":"1","name":"Ada"}"""))
val repo = UserRepository(fake.client)
val user = repo.getUser("1")
assertEquals("Ada", user.name)
assertEquals(1, fake.requests.size)
}
@Test
fun handlesUnauthorized() = runTest {
val fake = FakeNetworkClient()
fake.enqueue(testResponse(status = HttpStatus.Unauthorized))
val repo = UserRepository(fake.client)
assertIs<NetworkResult.Failure>(repo.getUser("1"))
}
}@OptIn(ExperimentalCaterktor::class)
class UserApiTest {
@Test
fun fetchesUserFromRoute() = runTest {
val server = CaterktorTestServer()
server.route(HttpMethod.GET, "/users/me", jsonResponse("""{"id":"1","name":"Ada"}"""))
val client = server.client { addConverter(KotlinxJsonConverter()) }
val result = client.get<User>("/users/me")
assertIs<NetworkResult.Success<User>>(result)
assertEquals("Ada", result.body.name)
}
}Rules can also match path templates and expose path parameters:
val fake = FakeTransport {
get("/users/{id}") { match ->
jsonResponse("""{"id":"${match.pathParameters["id"]}"}""")
}
}Test helpers at a glance:
testResponse(status = HttpStatus.OK, body = byteArrayOf()) // blank 200
jsonResponse("""{"key":"value"}""") // 200 + Content-Type: application/json
httpFailure(HttpStatus.NotFound) // NetworkError.Http pre-builtNetworkClient.events is a SharedFlow<NetworkEvent>. Collect it to wire any observability
backend — structured logs, metrics, traces — without coupling to the interceptor chain.
@OptIn(ExperimentalCaterktor::class)
scope.launch {
client.events.collect { event ->
when (event) {
is NetworkEvent.CallStart -> metrics.requestStarted(event.requestId)
is NetworkEvent.CallSuccess -> metrics.recordLatency(event.requestId, event.durationMs)
is NetworkEvent.CallFailure -> metrics.recordError(event.requestId, event.error)
is NetworkEvent.UploadProgress ->
metrics.uploaded(event.requestId, event.bytesSent, event.totalBytes)
is NetworkEvent.DownloadProgress ->
metrics.downloaded(event.requestId, event.bytesRead, event.totalBytes)
is NetworkEvent.ResponseReceived -> {}
is NetworkEvent.CircuitBreakerTransition ->
log.warn("Circuit ${event.name}: ${event.from} → ${event.to}")
}
}
}Progress totals are nullable because chunked requests, generated streams, and some server
responses do not know their final length up front. Treat null as indeterminate progress:
fun percent(done: Long, total: Long?): Int? =
total?.takeIf { it > 0L }?.let { ((done * 100) / it).toInt() }caterktor-logging also includes an event-derived logger for the same flow:
val eventLogger = NetworkEventLogger { line -> println(line) }
scope.launch {
client.events.collect(eventLogger::log)
}requestId is consistent across all events for the same logical call — start, retries, refresh
waits, and final outcome all share one ID, making log correlation across interceptors exact.
For large responses, use the Ktor-backed block-scoped streaming API. The body source is one-shot and must be consumed inside the block; Ktor releases the underlying response resources when the block returns.
val bytesWritten = transport.download(
NetworkRequest(HttpMethod.GET, "https://cdn.example.com/archive.zip"),
requestId = "archive-download",
onDownloadProgress = { progress ->
val label = progress.totalBytes?.let { "${progress.bytesRead}/$it" }
?: "${progress.bytesRead} bytes"
println("download ${progress.requestId}: $label")
},
) { response ->
val source = response.body.source()
try {
source.transferTo(fileSink)
} finally {
source.close()
}
}If you are already using NetworkClient.events, a streaming ResponseBody.Source returned by a
custom transport also emits NetworkEvent.DownloadProgress as the source is consumed. The
Ktor-specific callback above exists for the lower-level KtorTransport.download(...) escape hatch,
which runs outside the NetworkClient.events flow.
Typed helpers such as client.get<T>() still buffer up to maxBodyDecodeBytes before decoding,
which is the right behavior for JSON/protobuf models. Use KtorTransport.download(...) for file
or blob downloads.
Use RequestBody.Form for application/x-www-form-urlencoded requests:
val body = RequestBody.Form(
RequestBody.Form.Field("grant_type", "refresh_token"),
RequestBody.Form.Field("refresh_token", refreshToken),
)Use RequestBody.Multipart for form-data uploads:
val body = RequestBody.Multipart(
RequestBody.Multipart.Part.field("title", "avatar"),
RequestBody.Multipart.Part.formData(
name = "file",
filename = "avatar.png",
body = RequestBody.Source(
sourceFactory = { imageSource() },
contentType = "image/png",
),
),
)Source-backed multipart parts stream through KtorTransport without first materializing the file
body. When the request goes through NetworkClient, source-backed parts also emit
NetworkEvent.UploadProgress:
val uploadEvents = scope.launch {
client.events.collect { event ->
if (event is NetworkEvent.UploadProgress) {
println("uploaded ${event.bytesSent} of ${event.totalBytes ?: "unknown"}")
}
}
}
client.execute(
NetworkRequest(
method = HttpMethod.POST,
url = "https://api.example.com/profile/avatar",
body = body,
),
)Use contentLength when you know it. That gives progress UIs a determinate total; omit it for
chunked or generated streams:
RequestBody.Source(
sourceFactory = { fileSystem.source(path) },
contentType = "application/octet-stream",
contentLength = fileSize,
)@OptIn(ExperimentalCaterktor::class)
val client = CaterKtor {
transport = OkHttpTransport()
timeout {
requestTimeoutMs = 30_000L // per attempt
}
}Per-call deadlines are passed to typed helpers through the deadline parameter and propagate
through Chain, so retry delays and auth refresh waits can honor the same logical budget.
Always handle both variants. NetworkResult is sealed. The compiler will warn on a non-exhaustive
when. Do not suppress the warning — handle the Failure branch.
Do not catch CancellationException. CaterKtor never wraps it. If a coroutine is cancelled
mid-request, the exception propagates to the scope that owns the coroutine. Catching it breaks
structured concurrency.
Keep the NetworkClient as a singleton. Constructing a new client per request creates a new
Ktor HttpClient and its underlying engine thread pool. One client per logical backend (API, CDN,
internal service) is the right granularity.
Use describePipeline() when debugging ordering issues. The output is the contract — the list
matches the exact execution order at runtime.
Scope @OptIn(ExperimentalCaterktor::class) to the file, not the module. This makes it easy
to find and update call sites when surfaces stabilise.
CaterKtor is moving from 0.2.0 into 0.3.0. The next release is a maturity
release: progress telemetry lands, while adapter/platform candidates ship only
when their release gates are proven locally.
0.2.0 expanded the transport,
testing, observability, platform, and realtime surfaces while keeping the core
pipeline BCV-gated.
KtorTransport.download(request) { response -> ... }
RequestBody.Multipart and RequestBody.Form for file upload and form submission/users/{id}
CaterktorHttpServer for real TCP integration testsNetworkEventLogger
ConnectivityProbe support via caterktor-connectivity
caterktor-websocket
caterktor-sse
NetworkEvent
KtorTransport.download(...) progress callback for lower-level streaming downloadswasmJsTest and publication gates@ExperimentalCaterktor audit before any selective API graduationInterceptor and Chain graduate out of @ExperimentalCaterktor
These are out of scope and will not be added:
BodyConverter implementationThese are honest limitations of the current release, not bugs that slipped through:
Regular execute() and typed helpers still buffer responses. Use
KtorTransport.download(request) { ... } for large file/blob downloads. Typed decoding remains
bounded by maxBodyDecodeBytes.
Progress events are 0.3.0 scope. They are additive to lifecycle events and report nullable totals because many streaming bodies do not know their length up front.
CaterktorTestServer is still in-memory by design. Use JVM-only
CaterktorHttpServer when tests need a real TCP socket and HTTP framing semantics.
@ExperimentalCaterktor is required on most public API surfaces. Graduation is intentionally
conservative; APIs stay experimental when adapter/platform work may still reshape them.
OTel and Ktorfit adapters are not yet released. These modules remain reserved until local all-target release gates prove they can share CaterKtor's runtime semantics.
Contributions are welcome. Before opening a pull request:
Open an issue first for any non-trivial change — a quick alignment on direction saves wasted effort on both sides.
Run the full verification gate locally:
./gradlew check apiCheckAll tests must pass on all enabled targets.
Public API changes require an API dump update:
./gradlew apiDumpCommit the updated .api files alongside the code change.
Match the existing code style. The codebase uses explicitApi(), KDoc on every public
symbol, and @ExperimentalCaterktor on surfaces that may change.
Tests are not optional. New interceptors need unit tests against FakeNetworkClient.
New transport behaviour needs tests against CaterktorTestServer.
See CONTRIBUTING.md for the full guide, and CODE_OF_CONDUCT.md for community standards. Security issues should follow the process in SECURITY.md.
Copyright 2024 Samuel Oyedele
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.