In the Compose series, you learned basic Retrofit: define an interface, call an endpoint, show the result. That works for tutorials.
In production, you need more. Authentication tokens that refresh automatically. Logging for debugging. Retry logic for flaky networks. Proper error handling that shows meaningful messages.
This tutorial builds a production-ready API layer with Retrofit 3, Kotlin Serialization, and OkHttp interceptors.
Prerequisites: Compose Tutorial #12: Retrofit basics and Kotlin Tutorial: Serialization.
Why Kotlin Serialization Over Gson?
Retrofit traditionally used Gson for JSON parsing. Kotlin Serialization is better for modern Android:
| Feature | Gson | Kotlin Serialization |
|---|---|---|
| Kotlin-first | No | Yes |
| Null safety | Ignores | Enforces |
| Compile-time safety | No | Yes |
| KMP support | No | Yes |
| Speed | Good | Faster (no reflection) |
| Default values | Ignores | Respects |
Kotlin Serialization uses a compiler plugin to generate serializers — no reflection at runtime. If a required field is missing or null in the JSON, it throws a MissingFieldException at the parsing site (not later in UI code), making errors much easier to trace. Gson silently sets non-nullable fields to null and lets the crash happen elsewhere.
Setup
Dependencies
# gradle/libs.versions.toml
[versions]
retrofit = "3.0.0"
okhttp = "5.3.0"
serialization = "1.8.1"
[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
}
API Interface
Define your API endpoints with Retrofit annotations:
interface TaskApi {
@GET("tasks")
suspend fun getTasks(): List<TaskDto>
@GET("tasks/{id}")
suspend fun getTaskById(@Path("id") taskId: Long): TaskDto
@POST("tasks")
suspend fun createTask(@Body task: CreateTaskRequest): TaskDto
@PUT("tasks/{id}")
suspend fun updateTask(
@Path("id") taskId: Long,
@Body task: UpdateTaskRequest
): TaskDto
@DELETE("tasks/{id}")
suspend fun deleteTask(@Path("id") taskId: Long)
@GET("tasks")
suspend fun searchTasks(
@Query("q") query: String,
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): TaskListResponse
}
Data Transfer Objects (DTOs)
@Serializable
data class TaskDto(
val id: Long,
val title: String,
val description: String = "",
@SerialName("is_completed")
val isCompleted: Boolean = false,
@SerialName("created_at")
val createdAt: String,
@SerialName("updated_at")
val updatedAt: String? = null
)
@Serializable
data class CreateTaskRequest(
val title: String,
val description: String = ""
)
@Serializable
data class UpdateTaskRequest(
val title: String? = null,
val description: String? = null,
@SerialName("is_completed")
val isCompleted: Boolean? = null
)
@Serializable
data class TaskListResponse(
val data: List<TaskDto>,
val total: Int,
val page: Int,
@SerialName("total_pages")
val totalPages: Int
)
Use @SerialName when the JSON field name differs from your Kotlin property name. Use default values for optional fields.
Building Retrofit with Hilt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true // Don't crash on extra fields
coerceInputValues = true // Use defaults for invalid values
isLenient = true // Accept slightly malformed JSON
encodeDefaults = false // Don't send default values in requests
}
@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
@Provides
@Singleton
fun provideTaskApi(retrofit: Retrofit): TaskApi {
return retrofit.create(TaskApi::class.java)
}
}
OkHttp Interceptors
Interceptors modify requests and responses before they reach Retrofit.
Auth Interceptor — Adding Tokens
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenManager.getAccessToken()
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
Token Refresh with Authenticator
When a 401 response arrives, OkHttp’s Authenticator can refresh the token automatically:
class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val authApi: Lazy<AuthApi> // Lazy to avoid circular dependency
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// Don't retry if we already tried refreshing
if (response.request.header("X-Retry") != null) {
return null
}
synchronized(this) {
val refreshToken = tokenManager.getRefreshToken() ?: return null
// Try to refresh the token
val newTokens = runBlocking {
try {
authApi.get().refreshToken(RefreshRequest(refreshToken))
} catch (e: Exception) {
null
}
} ?: return null
tokenManager.saveTokens(newTokens.accessToken, newTokens.refreshToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.header("X-Retry", "true")
.build()
}
}
}
Add the authenticator to OkHttp:
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator) // Handles 401 responses
.build()
Retry Interceptor
Automatically retry failed requests:
class RetryInterceptor(private val maxRetries: Int = 3) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var attempt = 0
var lastException: IOException? = null
while (attempt < maxRetries) {
try {
val response = chain.proceed(chain.request())
if (response.isSuccessful || response.code !in 500..599) {
return response
}
response.close()
} catch (e: IOException) {
lastException = e
}
attempt++
Thread.sleep(1000L * attempt) // Exponential backoff
}
throw lastException ?: IOException("Max retries reached")
}
}
Error Handling
Domain Error Model
sealed interface ApiError {
data class HttpError(val code: Int, val message: String) : ApiError
data class NetworkError(val cause: Throwable) : ApiError
data class ParseError(val cause: Throwable) : ApiError
data object UnknownError : ApiError
}
Safe API Call Wrapper
suspend fun <T> safeApiCall(
apiCall: suspend () -> T
): Result<T> {
return try {
Result.Success(apiCall())
} catch (e: HttpException) {
val errorBody = e.response()?.errorBody()?.string()
Result.Error(
"HTTP ${e.code()}: ${parseErrorMessage(errorBody)}",
e
)
} catch (e: IOException) {
Result.Error("No internet connection", e)
} catch (e: SerializationException) {
Result.Error("Failed to parse server response", e)
} catch (e: Exception) {
Result.Error("Something went wrong", e)
}
}
private fun parseErrorMessage(errorBody: String?): String {
return try {
val json = Json { ignoreUnknownKeys = true }
val error = json.decodeFromString<ErrorResponse>(errorBody ?: "")
error.message
} catch (_: Exception) {
"Server error"
}
}
@Serializable
data class ErrorResponse(
val message: String,
val code: String? = null
)
Using It in the Repository
class TaskRepositoryImpl @Inject constructor(
private val api: TaskApi,
private val dao: TaskDao
) : TaskRepository {
override suspend fun refreshTasks(): Result<Unit> {
return safeApiCall {
val tasks = api.getTasks()
dao.deleteAll()
dao.insertAll(tasks.map { it.toEntity() })
}
}
override suspend fun createTask(title: String): Result<Task> {
return safeApiCall {
val dto = api.createTask(CreateTaskRequest(title))
val entity = dto.toEntity()
dao.insert(entity)
entity.toDomain()
}
}
}
Multipart Uploads
Sending files (like images) to the server:
interface UploadApi {
@Multipart
@POST("upload/avatar")
suspend fun uploadAvatar(
@Part image: MultipartBody.Part,
@Part("description") description: RequestBody
): AvatarResponse
}
class UploadRepository @Inject constructor(
private val api: UploadApi
) {
suspend fun uploadAvatar(imageUri: Uri, context: Context): Result<String> {
return safeApiCall {
val bytes = context.contentResolver.openInputStream(imageUri)
?.use { it.readBytes() }
?: throw IOException("Cannot open file")
val requestBody = bytes.toRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("avatar", "avatar.jpg", requestBody)
val description = "Profile avatar".toRequestBody("text/plain".toMediaType())
val response = api.uploadAvatar(part, description)
response.url
}
}
}
Network Connectivity Monitoring
Check if the device has internet before making requests:
class NetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) {
val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager?.registerNetworkCallback(request, callback)
// Check initial state
val currentNetwork = connectivityManager?.activeNetwork
val capabilities = connectivityManager?.getNetworkCapabilities(currentNetwork)
trySend(capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)
awaitClose {
connectivityManager?.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
}
Use it in your repository to decide when to sync:
class TaskRepositoryImpl @Inject constructor(
private val api: TaskApi,
private val dao: TaskDao,
private val networkMonitor: NetworkMonitor
) : TaskRepository {
override fun getTasks(): Flow<List<Task>> {
return dao.getAll()
.map { it.map { e -> e.toDomain() } }
.onStart {
networkMonitor.isOnline.first().let { online ->
if (online) refreshTasks()
}
}
}
}
Testing API Calls with MockWebServer
MockWebServer from OkHttp lets you test API calls without a real server:
class TaskApiTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var api: TaskApi
@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()
val json = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
api = retrofit.create(TaskApi::class.java)
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `getTasks returns task list`() = runTest {
val responseJson = """
[
{"id": 1, "title": "Buy groceries", "is_completed": false, "created_at": "2026-01-01T00:00:00Z"},
{"id": 2, "title": "Write code", "is_completed": true, "created_at": "2026-01-02T00:00:00Z"}
]
""".trimIndent()
mockWebServer.enqueue(MockResponse().setBody(responseJson).setResponseCode(200))
val tasks = api.getTasks()
assertEquals(2, tasks.size)
assertEquals("Buy groceries", tasks[0].title)
assertFalse(tasks[0].isCompleted)
}
@Test
fun `createTask sends correct request body`() = runTest {
mockWebServer.enqueue(MockResponse().setBody("""
{"id": 3, "title": "New task", "created_at": "2026-01-03T00:00:00Z"}
""").setResponseCode(201))
api.createTask(CreateTaskRequest("New task"))
val request = mockWebServer.takeRequest()
assertEquals("POST", request.method)
assertTrue(request.body.readUtf8().contains("New task"))
}
}
Add the test dependency:
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.0")
Ktor as an Alternative
If you plan to share networking code across Android and iOS with KMP, consider Ktor HttpClient instead of Retrofit. Ktor is Kotlin-native and supports all KMP targets.
Check the KMP tutorial series for details on Ktor.
For Android-only projects, Retrofit is still the best choice — it has a larger ecosystem, more documentation, and better tooling.
What’s Next?
In this tutorial, you learned:
- Why Kotlin Serialization is better than Gson
- Setting up Retrofit 3 with KSP and Hilt
- OkHttp interceptors for logging, auth, and retry
- Automatic token refresh with Authenticator
- Error handling with sealed classes
- Multipart uploads and network monitoring
- Testing with MockWebServer
Next up: Android Tutorial #7: Room Database — Advanced Patterns — migrations, relations, FTS search, and type converters.
Related Articles
- Compose Tutorial #12: Retrofit Basics — getting started
- Kotlin Tutorial: Serialization — kotlinx.serialization
- Android Tutorial #3: Repository Pattern — data layer
- Android Tutorial #2: Hilt — dependency injection
This is part 6 of the Android Development Tutorial series.