Kotlin Retrofit — HTTP Client and API Integration Guide
In this tutorial, you will learn about Kotlin Retrofit. We cover key concepts, practical examples, and best practices to help you master this topic.
Retrofit is a type-safe HTTP client for Android and Kotlin that turns REST API interfaces into Kotlin callable objects with annotation-driven request configuration and automatic JSON serialization.
What You'll Learn
- Define REST API interfaces with Retrofit annotations
- Configure Retrofit with OkHttp and JSON converters
- Use suspend functions for coroutine-based requests
- Handle errors and network exceptions
- Add interceptors for logging, auth, and caching
- Parse complex JSON responses with Moshi
- Upload files and multipart requests
- Test API calls with mock interceptors
Why It Matters
Nearly every modern app communicates with a backend API. Retrofit is the de facto standard HTTP client on Android. It eliminates manual JSON Parsing, thread management, and connection handling. Combined with Kotlin coroutines, API calls become simple suspend functions that integrate directly with ViewModel and Compose.
Real-World Use
DodaTech's Android app uses Retrofit to communicate with signature update servers, submit scan results, and fetch configuration. Interceptors add authentication headers, log request timings, and cache responses. Custom error handling maps HTTP errors to domain-specific sealed class results.
Learning Path
flowchart LR A[Room Database] --> B[Retrofit\nYou are here] B --> C[Navigation] style B fill:#f90,color:#fff
Adding Dependencies
// build.gradle.kts (app level)
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
// JSON converter (Moshi)
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
// OkHttp (included with Retrofit, but explicit for interceptors)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// For testing
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}
Defining the API Interface
Retrofit uses annotations to describe HTTP requests.
import retrofit2.http.*
data class UserResponse(
val id: Long,
val name: String,
val email: String,
val avatarUrl: String?
)
data class CreateUserRequest(
val name: String,
val email: String,
val password: String
)
data class ApiError(
val message: String,
val code: Int
)
interface ApiService {
// GET request
@GET("users")
suspend fun getUsers(): List<UserResponse>
@GET("users/{id}")
suspend fun getUserById(@Path("id") userId: Long): UserResponse
// POST with body
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): UserResponse
// PUT update
@PUT("users/{id}")
suspend fun updateUser(
@Path("id") userId: Long,
@Body updates: Map<String, @JvmSuppressWildcards Any>
): UserResponse
// DELETE
@DELETE("users/{id}")
suspend fun deleteUser(@Path("id") userId: Long)
// Query parameters
@GET("users")
suspend fun searchUsers(
@Query("q") query: String,
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): List<UserResponse>
// Custom headers
@Headers("X-API-Version: 2")
@GET("users/me")
suspend fun getCurrentUser(): UserResponse
// Dynamic headers
@GET("protected/resource")
suspend fun getProtectedResource(
@Header("Authorization") authToken: String
): UserResponse
// URL encoded form
@FormUrlEncoded
@POST("auth/login")
suspend fun login(
@Field("email") email: String,
@Field("password") password: String
): TokenResponse
// Multipart file upload
@Multipart
@POST("upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
@Part("description") description: RequestBody
): UploadResponse
}
Each function is a suspend function for coroutine compatibility. Annotations describe the HTTP method, path, and parameter binding.
Creating the Retrofit Instance
Configure Retrofit with a base URL, converters, and OkHttp client.
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
object NetworkModule {
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(AuthInterceptor())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
val apiService: ApiService = retrofit.create(ApiService::class.java)
}
Using Retrofit with ViewModel
Call API methods from ViewModel with coroutines.
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
data object Loading : ApiResult<Nothing>()
}
class UserListViewModel(
private val apiService: ApiService = NetworkModule.apiService
) : ViewModel() {
private val _users = MutableStateFlow<ApiResult<List<UserResponse>>>(ApiResult.Loading)
val users: StateFlow<ApiResult<List<UserResponse>>> = _users.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
_users.value = ApiResult.Loading
try {
val response = apiService.getUsers()
_users.value = ApiResult.Success(response)
} catch (e: Exception) {
val message = when (e) {
is java.net.UnknownHostException -> "No internet connection"
is java.net.SocketTimeoutException -> "Request timed out"
is retrofit2.HttpException -> {
when (e.code()) {
401 -> "Unauthorized"
404 -> "Not found"
500 -> "Server error"
else -> "HTTP ${e.code()}"
}
}
else -> e.message ?: "Unknown error"
}
_users.value = ApiResult.Error(message)
}
}
}
}
// Compose usage
@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
val result by viewModel.users.collectAsState()
when (val state = result) {
is ApiResult.Loading -> CircularProgressIndicator()
is ApiResult.Error -> Text("Error: ${state.message}", color = Color.Red)
is ApiResult.Success -> {
LazyColumn {
items(state.data) { user ->
UserItem(user)
}
}
}
}
}
Output: The UI shows loading, error, or success states based on the API call result.
Interceptors
Add behavior to every HTTP request and response through interceptors.
Authentication Interceptor
class AuthInterceptor(
private val tokenProvider: () -> String?
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val token = tokenProvider()
val request = if (token != null) {
originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
originalRequest
}
return chain.proceed(request)
}
}
Cache Interceptor
class CacheInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
return when {
isNetworkAvailable() -> {
// Online: cache for 1 minute
response.newBuilder()
.header("Cache-Control", "public, max-age=60")
.build()
}
else -> {
// Offline: serve cached data for up to 7 days
response.newBuilder()
.header("Cache-Control", "public, only-if-cached, max-stale=604800")
.build()
}
}
}
}
Error Handling
Handle errors gracefully with a sealed result wrapper.
suspend fun <T> safeApiCall(
apiCall: suspend () -> T
): ApiResult<T> {
return try {
ApiResult.Success(apiCall())
} catch (e: Exception) {
when (e) {
is retrofit2.HttpException -> {
val errorBody = e.response()?.errorBody()?.string()
val message = parseErrorMessage(errorBody)
ApiResult.Error(message, e.code())
}
is java.net.UnknownHostException -> {
ApiResult.Error("No internet connection")
}
is java.net.SocketTimeoutException -> {
ApiResult.Error("Connection timed out")
}
else -> {
ApiResult.Error(e.message ?: "Unknown error")
}
}
}
}
private fun parseErrorMessage(errorBody: String?): String {
return try {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val adapter = moshi.adapter(ApiError::class.java)
adapter.fromJson(errorBody ?: "")?.message ?: "Unknown error"
} catch (e: Exception) {
"Unknown error"
}
}
File Upload
Use @Multipart for file uploads.
class FileUploadService(private val apiService: ApiService) {
suspend fun uploadImage(uri: Uri, context: Context): ApiResult<UploadResponse> {
return safeApiCall {
val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(uri) ?: throw Exception("Cannot open file")
val bytes = inputStream.readBytes()
val requestBody = bytes.toRequestBody("image/*".toMediaType())
val multipartBody = MultipartBody.Part.createFormData(
"file",
"upload.jpg",
requestBody
)
val description = "Image upload".toRequestBody("text/plain".toMediaType())
apiService.uploadFile(multipartBody, description)
}
}
}
Testing with MockWebServer
Use OkHttp's MockWebServer for testing without real network calls.
class ApiServiceTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var apiService: ApiService
@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()
apiService = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(ApiService::class.java)
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun testGetUsersSuccess() = runTest {
val json = """
[
{"id": 1, "name": "Alice", "email": "alice@test.com", "avatarUrl": null},
{"id": 2, "name": "Bob", "email": "bob@test.com", "avatarUrl": "https://example.com/avatar.jpg"}
]
""".trimIndent()
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json)
)
val users = apiService.getUsers()
assertEquals(2, users.size)
assertEquals("Alice", users[0].name)
}
@Test
fun testGetUsersError() = runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(401)
.setBody("""{"message": "Unauthorized", "code": 401}""")
)
try {
apiService.getUsers()
fail("Expected exception")
} catch (e: HttpException) {
assertEquals(401, e.code())
}
}
}
Output: Tests use mocked responses without real network calls.
Common Mistakes
Not using suspend functions: Retrofit calls must be in a coroutine context. Using Call or Callback patterns instead of suspend mixes paradigms.
Forgetting to handle network errors: Network calls throw exceptions on connectivity issues. Always wrap API calls in try-catch or use a sealed result pattern.
Using LiveData with Retrofit instead of StateFlow: LiveData with Retrofit requires manual transformation. StateFlow integrates naturally with coroutines.
Not configuring timeouts: Default OkHttp timeouts are generous. Set reasonable connect/read/write timeouts for a better user experience.
Hardcoding base URLs: Store the base URL in BuildConfig or a configuration file. Different environments (dev, staging, production) need different URLs.
Ignoring response caching: Without caching, every screen load makes a network call. Use OkHttp caching and appropriate Cache-Control headers.
Practice Questions
- What is the purpose of the @Body annotation in Retrofit?
Answer: @Body marks a parameter as the HTTP request body. Retrofit serializes the parameter using the configured converter (JSON by default).
- How do you add authentication headers to all requests?
Answer: Create an OkHttp Interceptor that adds the Authorization header to every request. Register it with the OkHttpClient.Builder.
- What is the difference between @Query and @Path?
Answer: @Query appends a query parameter to the URL (?key=value). @Path replaces a placeholder in the URL path ({id} becomes the actual value).
- How do you handle JSON date parsing in Retrofit?
Answer: Add a Moshi Adapter for the Date type: .add(Date::class.java, RxJava2AdapterFactory.create()). Or use a @Json annotation with a custom adapter.
- Challenge: Build a Retrofit client with exponential backoff retry logic. On network failures, retry up to 3 times with 1s, 2s, and 4s delays. Use a custom interceptor or OkHttp's built-in retry.
Answer:
class RetryInterceptor(private val maxRetries: Int = 3) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var retryCount = 0
var lastException: Exception? = null
while (retryCount <= maxRetries) {
try {
val response = chain.proceed(chain.request())
if (response.isSuccessful) return response
// Retry on server errors
if (response.code in 500..599 && retryCount < maxRetries) {
response.close()
retryCount++
val delayMs = 1000L * (1 shl (retryCount - 1)) // 1s, 2s, 4s
Thread.sleep(delayMs)
continue
}
return response
} catch (e: Exception) {
lastException = e
if (retryCount >= maxRetries) break
retryCount++
val delayMs = 1000L * (1 shl (retryCount - 1))
Thread.sleep(delayMs)
}
}
throw lastException ?: IOException("Request failed after $maxRetries retries")
}
}
Mini Project
Build a GitHub user search app. Requirements:
- Retrofit interface for GitHub API (search users, get user repos)
- Moshi for JSON parsing
- ViewModel with StateFlow for results
- Search with debounce (delay search while typing)
- Error handling for network errors and API limits
- Pagination for search results
- Interceptor for rate limit tracking
This project consolidates all Retrofit patterns in a real-world API integration.
FAQ
What's Next
After mastering Retrofit, learn Navigation for moving between screens. You can also explore coroutines for deeper async understanding.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro