Skip to content

Kotlin Retrofit — HTTP Client and API Integration Guide

DodaTech Updated 2026-06-28 9 min read

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

  1. Not using suspend functions: Retrofit calls must be in a coroutine context. Using Call or Callback patterns instead of suspend mixes paradigms.

  2. 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.

  3. Using LiveData with Retrofit instead of StateFlow: LiveData with Retrofit requires manual transformation. StateFlow integrates naturally with coroutines.

  4. Not configuring timeouts: Default OkHttp timeouts are generous. Set reasonable connect/read/write timeouts for a better user experience.

  5. Hardcoding base URLs: Store the base URL in BuildConfig or a configuration file. Different environments (dev, staging, production) need different URLs.

  6. Ignoring response caching: Without caching, every screen load makes a network call. Use OkHttp caching and appropriate Cache-Control headers.

Practice Questions

  1. 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).

  1. 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.

  1. 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).

  1. 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.

  1. 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 is the difference between Retrofit and OkHttp?

OkHttp is the low-level HTTP client. Retrofit wraps OkHttp with a type-safe interface using annotations. You can use OkHttp directly, but Retrofit is preferred for most API calls.

Can Retrofit handle WebSocket connections?

No. Retrofit is for request-response HTTP calls only. Use OkHttp's WebSocket support for real-time connections.

How do I cancel a Retrofit request?

Cancel the coroutine that launched the request. viewModelScope cancels all child coroutines automatically when the ViewModel is cleared.

What is the best JSON converter for Retrofit?

Moshi is recommended for Kotlin projects. It handles nullability, default values, and Kotlin-specific types better than Gson.

How do I debug Retrofit requests?

Add the HttpLoggingInterceptor with Level.BODY or Level.HEADERS. It logs request and response details to Logcat.

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