arrow-http
1.1.1indexedModular, client-agnostic HTTP toolkit offering type-safe error hierarchy, interceptor policies, automatic auth refresh, retry strategies, flexible request configs, multipart uploads and pluggable client implementations.
Modular, client-agnostic HTTP toolkit offering type-safe error hierarchy, interceptor policies, automatic auth refresh, retry strategies, flexible request configs, multipart uploads and pluggable client implementations.
A modular, client-agnostic HTTP library for Kotlin Multiplatform projects. Write your HTTP code once, run it everywhere.
Author: Emmanuel Conradie
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
// For Kotlin Multiplatform
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.blackarrows-apps:http-core:1.3.0")
implementation("io.github.blackarrows-apps:http-ktor:1.3.0")
}
}
}
}
import io.blackarrows.http.errors.*
try {
val users = repository.getUsers()
// Success
} catch (e: AuthException) {
when (e.errorCode) {
ErrorCodes.AUTH_TOKEN_EXPIRED -> refreshTokenAndRetry()
ErrorCodes.AUTH_INSUFFICIENT_PERMISSIONS -> showPermissionDenied()
}
} catch (e: NetworkException) {
if (e.isRetryable) {
scheduleRetry()
} {
showNetworkError()
}
} (e: HttpStatusException) {
(e.statusCode) {
-> showNotFound()
-> handleRateLimiting()
-> showServerError()
}
}
Client-agnostic abstractions and interfaces. Contains:
HttpRequestExecutor - Main interface for HTTP operationsHttpHeaders & HttpRequestConfig - Framework-independent typesApiResponse - Response wrapper with JSON deserializationZero implementation dependencies - Write your business logic once, swap clients anytime.
Ktor-based implementation with platform-specific optimizations:
http-core and also target (browser + Node.js), published in library
mode with generated TypeScript definitions () — usable directly from a TypeScript
project via npm once published. , , and the
hierarchy export cleanly via ; itself does not, since
Kotlin/JS cannot export declarations — a TypeScript-facing wrapper exposing
-returning methods is future work, not part of this release. The npm package
name/registry is a decision for the actual publish step, not fixed here.
Additional HTTP client implementations will be added based on community demand:
http-okhttp - Direct OkHttp implementation for Androidhttp-urlconnection - Pure Java URLConnection for lightweight JVM appsWant a specific client? Open an issue to request it!
publication-consumer is an independent Gradle build. It has no
project dependency, composite-build substitution, or mavenLocal() repository. It resolves
the immutable Maven coordinates and compiles both Apple Silicon variants. Its simulator test
constructs and closes the public Ktor client, proving that the Darwin engine is usable without
making a live network request.
The fixture defaults to the pending corrective 1.3.0 release and accepts a staging
repository URL, so the exact Maven output can be verified before Central promotion:
./gradlew -p publication-consumer \
-ParrowHttpRepository="file://$PWD/build/verification-repository" \
compileKotlinIosArm64 compileKotlinIosSimulatorArm64 iosSimulatorArm64Test
Published 1.2.0 cannot compile as an external Apple consumer: its root metadata advertises
http-core-iosarm64, http-core-iossimulatorarm64, http-ktor-iosarm64, and
http-ktor-iossimulatorarm64, but those target modules were not uploaded. Its iOS
also contained a , which would throw if reached. Maven Central
releases are immutable, so is the corrective release.
Before promoting 1.3.0, publish all modules to a clean staging repository, then point the
fixture exclusively at that repository for owned coordinates:
./gradlew publishAllPublicationsToVerificationRepository
./gradlew -p publication-consumer \
-ParrowHttpVersion=1.3.0 \
-ParrowHttpRepository="file://$PWD/build/verification-repository" \
compileKotlinIosArm64 compileKotlinIosSimulatorArm64 iosSimulatorArm64Test
The verification repository is deliberately local and separate from mavenLocal(). After
Central promotion, repeat the consumer command without arrowHttpRepository.
Comprehensive exception hierarchy for precise error handling:
sealed class HttpException
├── NetworkException // Network failures (retryable)
├── AuthException // Auth/permission errors
├── HttpStatusException // HTTP status codes (4xx, 5xx)
├── TimeoutException // Request timeouts
└── SerializationException // JSON parsing errors
Each exception includes:
Policy-based interceptor system for cross-cutting concerns:
val executor = KtorHttpRequestExecutor(
client = httpClient,
authHeaderProvider = headerProvider,
policies = listOf(
AuthPolicy(authRefresher, maxRetries = 2), // Automatic token refresh
RetryPolicy(maxRetries = 3, exponentialBackoff = true) // Retry transient failures
)
)
AuthPolicy: Automatically refreshes expired tokens and retries requests RetryPolicy: Handles transient network failures with exponential backoff
All common HTTP operations with typed requests and responses:
// GET
executor.getJson(url, queryParams, headers, authRequired)
executor.getRaw(url, headers, queryParams, authRequired)
// POST
executor.postJson(url, body, headers, authRequired)
executor.postForm(url, formParams, headers, authRequired)
executor.postMultipart(url, multipartForm, headers, authRequired)
executor.postQuery(url, queryParams, contentType, headers, authRequired)
// PUT
executor.putJson(url, body, headers, authRequired)
executor.putRaw(url, body, contentType, headers, authRequired)
// PATCH
executor.patchJson(url, body, headers, authRequired)
executor.patchRaw(url, body, contentType, headers, authRequired)
// DELETE
executor.deleteJson(url, body, headers, authRequired)
executor.deleteRaw(url, body, contentType, headers, authRequired)
Simple multipart form data support:
val form = MultipartForm(
fields = mapOf("description" to "Profile picture"),
files = listOf(
MultipartPart(
name = "avatar",
value = imageBytes,
filename = "avatar.jpg",
contentType = "image/jpeg"
)
)
)
executor.postMultipart("https://api.example.com/upload", form, authRequired = true)
Per-request configuration:
val config = HttpRequestConfig(
headers = HttpHeaders.of("X-Request-ID" to "12345"),
queryParams = mapOf("debug" to "true"),
timeout = 60_000L,
followRedirects = false
)
val response = executor.getJson(url, config = config)
Key Principle: Your application code depends only on http-core abstractions. The implementation module (http-ktor) is a runtime dependency that can be swapped without changing your business logic.
If you prefer not to use dependency injection:
Mock the HttpRequestExecutor interface for easy testing:
Check out the sample module for a complete Android demo app that showcases:
The sample app demonstrates:
To run the sample app:
./gradlew :sample:assembleDebug
# or open in Android Studio and run the 'sample' configuration
Contributions are welcome! Please:
Want support for a different HTTP client? Open an issue with:
Client implementations will be prioritized based on community demand.
http-core)http-ktor)Copyright 2025 Emmanuel Conradie
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance the License.
You may obtain a copy of the License at
http:
Unless applicable law agreed to writing, software
distributed under the License distributed an BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
See the License the specific language governing permissions
limitations under the License.
Emmanuel Conradie GitHub: @E5c11
Built with ❤️ for the Kotlin Multiplatform community
import io.blackarrows.http.ktor.di.httpModule
import io.blackarrows.http.providers.HeaderProvider
import io.blackarrows.http.providers.AuthRefresher
import org.koin.core.context.startKoin
import org.koin.dsl.module
startKoin {
modules(
module {
single<HeaderProvider> {
object : HeaderProvider {
override suspend fun getHeaders(vararg additional: Pair<String, String>): Map<String, String> {
return mapOf(
"Authorization" to "Bearer ${getToken()}",
*additional
)
}
override fun invalidate() {
// Clear cached headers if needed
}
}
}
single<AuthRefresher> {
object : AuthRefresher {
override suspend fun refreshToken(): Result<String> {
// Implement token refresh logic
return Result.success("new_token")
}
}
}
},
httpModule
)
}
import io.blackarrows.http.io.HttpRequestExecutor
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
data class User(val id: Int, val name: String, val email: String)
class UserRepository(
private val httpExecutor: HttpRequestExecutor
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun getUsers(): Result<List<User>> {
return try {
val response = httpExecutor.getJson(
url = "https://api.example.com/users",
authRequired = true
)
val bodyString = response.body?.decodeToString() ?: "[]"
val users = json.decodeFromString(ListSerializer(User.serializer()), bodyString)
Result.success(users)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createUser(user: User): Result<User> {
return try {
val response = httpExecutor.postJson(
url = "https://api.example.com/users",
body = user,
authRequired = true
)
val bodyString = response.body?.decodeToString() ?: throw IllegalStateException("Empty response")
val createdUser = json.decodeFromString(User.serializer(), bodyString)
Result.success(createdUser)
} catch (e: Exception) {
Result.failure(e)
}
}
}
http-ktorjs.d.tsHttpHeadersApiResponseHttpException@JsExportHttpRequestExecutorsuspend funPromise| Platform | Status | Engine (Ktor) |
|---|
| Android | ✅ | OkHttp |
| JVM | ✅ | OkHttp |
| iOS arm64 | ✅ | Darwin |
| iOS x64 | ✅ | Darwin |
| iOS simulatorArm64 | ✅ | Darwin |
| wasmJs | ✅ | Js |
| js (browser + Node) | ✅ | Js |
createHttpClient()TODO1.3.0┌─────────────────────────────────────┐
│ Your Application Code │
│ (Platform: Android/iOS/JVM/Web) │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ http-core │
│ • HttpRequestExecutor │
│ • HttpHeaders, HttpRequestConfig │
│ • Exception Hierarchy │
│ • Interceptor System │
│ • ApiResponse │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ http-ktor (or other) │
│ • KtorHttpRequestExecutor │
│ • Platform-Specific Engines │
│ • JSON Serialization │
│ • Error Mapping │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Platform HTTP Implementation │
│ Android: OkHttp │
│ iOS: Darwin (NSURLSession) │
│ JVM: OkHttp │
│ wasmJs/js: Js (Fetch API) │
└─────────────────────────────────────┘
import io.blackarrows.http.ktor.createHttpClient
import io.blackarrows.http.ktor.KtorHttpRequestExecutor
import io.blackarrows.http.providers.HeaderProvider
import io.blackarrows.http.io.interceptors.*
// 1. Create HTTP client
val httpClient = createHttpClient {
install(HttpTimeout) {
requestTimeoutMillis = 60_000
}
}
// 2. Create header provider
val headerProvider = object : HeaderProvider {
override suspend fun getHeaders(vararg additional: Pair<String, String>): Map<String, String> {
return mapOf("Authorization" to "Bearer ${tokenStore.getToken()}")
}
}
// 3. Create policies
val authPolicy = AuthPolicy(
authRefresher = object : AuthRefresher {
override suspend fun refresh(): ReauthResult {
return try {
tokenStore.refreshToken()
ReauthResult.Success
} catch (e: Exception) {
ReauthResult.Failed
}
}
}
)
val retryPolicy = RetryPolicy(maxRetries = 3)
// 4. Create executor
val httpExecutor = KtorHttpRequestExecutor(
client = httpClient,
authHeaderProvider = headerProvider,
policies = listOf(authPolicy, retryPolicy)
)
class MockHttpExecutor : HttpRequestExecutor {
var mockResponse: ApiResponse? = null
override suspend fun getJson(
url: String,
queryParams: Map<String, String>,
headers: HttpHeaders,
authRequired: Boolean,
config: HttpRequestConfig
): ApiResponse {
return mockResponse ?: error("No mock response configured")
}
// Implement other methods...
}
fun testRepository() = runTest {
val mockExecutor = MockHttpExecutor()
mockExecutor.mockResponse = MockApiResponse(
statusCode = 200,
body = """[{"id":1,"name":"Test"}]"""
)
val repository = UserRepository(mockExecutor)
val users = repository.getUsers()
assertEquals(1, users.size)
assertEquals("Test", users[0].name)
}
Surfaced from shared tags and platforms — no rankings paid for.