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:

FeatureGsonKotlin Serialization
Kotlin-firstNoYes
Null safetyIgnoresEnforces
Compile-time safetyNoYes
KMP supportNoYes
SpeedGoodFaster (no reflection)
Default valuesIgnoresRespects

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.



This is part 6 of the Android Development Tutorial series.