Skip to main content
Technology & EngineeringAndroid Kotlin309 lines

Retrofit

Retrofit HTTP client for type-safe REST API communication in Android with Kotlin coroutines

Quick Summary18 lines
You are an expert in Retrofit for building Android applications with Kotlin.

## Key Points

- Use `suspend` functions in API interfaces for coroutine integration — avoid `Call<T>` wrappers.
- Return `Response<T>` only when you need access to HTTP status codes or headers; otherwise return the body type directly.
- Enable logging only in debug builds to avoid leaking sensitive data in production logs.
- Use `kotlinx.serialization` with `ignoreUnknownKeys = true` so new server fields do not break the client.
- Wrap API calls in a `safeApiCall` function that catches `HttpException` and `IOException` uniformly.
- Set reasonable timeouts — 30 seconds for connect and read is a sensible default.
- Use `@Streaming` for large file downloads to avoid loading the entire response into memory.
- Forgetting to add the `kotlinx-serialization-converter` or `moshi-converter` dependency, causing a runtime crash with no converter found.
- Not handling `HttpException` (non-2xx responses) and `IOException` (network failures) separately.
- Hardcoding base URLs instead of injecting them, making it impossible to switch between staging and production.
- Using `runBlocking` inside interceptors on the main thread — the `Authenticator` and `Interceptor` run on OkHttp's threads, so `runBlocking` is safe there but dangerous elsewhere.
- Logging sensitive headers (auth tokens) in production by leaving `HttpLoggingInterceptor.Level.BODY` on.
skilldb get android-kotlin-skills/RetrofitFull skill: 309 lines
Paste into your CLAUDE.md or agent config

Retrofit Networking — Android/Kotlin

You are an expert in Retrofit for building Android applications with Kotlin.

Core Philosophy

Retrofit's power lies in its declarative approach: you define your API as a Kotlin interface with annotated methods, and Retrofit generates the implementation. This means the API definition is also the documentation. A developer can look at the interface and immediately understand every endpoint, its HTTP method, path parameters, query parameters, and request/response types. When the interface is the single source of truth, there is no drift between what the code does and what the developer thinks it does.

The networking stack should be layered cleanly: Retrofit defines the API contract, OkHttp handles the actual HTTP transport, and a serialization library (kotlinx.serialization or Moshi) handles JSON conversion. Each layer is independently configurable and replaceable. Interceptors handle cross-cutting concerns (authentication, logging, retry) without polluting the API interface. This separation means you can add request signing, caching, or certificate pinning by adding an interceptor, not by modifying every API call.

Error handling in Retrofit requires explicit attention because the library does not throw on non-2xx responses by default when using suspend functions. A suspend fun getUser(): UserDto that receives a 404 response will throw an HttpException, but a suspend fun getUser(): Response<UserDto> will silently return the error response. Choose one pattern consistently and wrap it in a safeApiCall helper so that every repository method handles HttpException and IOException uniformly.

Anti-Patterns

  • Not handling HttpException and IOException separately: HttpException means the server responded with a non-2xx status code (a server-side problem). IOException means the request never completed (a network-side problem). Treating them identically means you cannot distinguish "user not found" from "no internet connection" in the UI.

  • Leaving HttpLoggingInterceptor at BODY level in production: The logging interceptor prints the full request and response body, including authentication tokens, API keys, and user data. In production, this leaks sensitive information to logs. Set the level to NONE or at most BASIC in release builds.

  • Hardcoding the base URL: Embedding "https://api.production.com" directly in the Retrofit builder makes it impossible to point the app at staging, QA, or local development servers without rebuilding. Inject the base URL from build configuration or environment variables.

  • Using runBlocking inside interceptors from the main thread: OkHttp interceptors run on OkHttp's own thread pool, so runBlocking is safe there. But if you accidentally invoke an interceptor synchronously from the main thread (rare but possible with misconfigured clients), runBlocking causes an ANR. Understand the threading model before using blocking calls.

  • Not using @Streaming for large file downloads: Without @Streaming, Retrofit buffers the entire response body in memory before returning it. For large files, this causes OutOfMemoryError. The @Streaming annotation returns a ResponseBody that can be read incrementally.

Overview

Retrofit is a type-safe HTTP client for Android and JVM that turns REST API definitions into Kotlin interfaces. Combined with kotlinx.serialization or Moshi for JSON parsing and OkHttp for the underlying HTTP layer, it is the standard networking stack for Android applications.

Core Concepts

API Interface Definition

interface ApiService {

    @GET("users")
    suspend fun getUsers(): List<UserDto>

    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: Long): UserDto

    @GET("users/search")
    suspend fun searchUsers(
        @Query("q") query: String,
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 20
    ): PaginatedResponse<UserDto>

    @POST("users")
    suspend fun createUser(@Body request: CreateUserRequest): UserDto

    @PUT("users/{id}")
    suspend fun updateUser(
        @Path("id") userId: Long,
        @Body request: UpdateUserRequest
    ): UserDto

    @DELETE("users/{id}")
    suspend fun deleteUser(@Path("id") userId: Long): Response<Unit>

    @Multipart
    @POST("users/{id}/avatar")
    suspend fun uploadAvatar(
        @Path("id") userId: Long,
        @Part image: MultipartBody.Part
    ): UserDto

    @GET("files/{name}")
    @Streaming
    suspend fun downloadFile(@Path("name") fileName: String): ResponseBody
}

Data Transfer Objects

@Serializable
data class UserDto(
    val id: Long,
    @SerialName("display_name")
    val displayName: String,
    val email: String,
    @SerialName("avatar_url")
    val avatarUrl: String? = null,
    @SerialName("created_at")
    val createdAt: String
)

@Serializable
data class CreateUserRequest(
    @SerialName("display_name")
    val displayName: String,
    val email: String
)

@Serializable
data class PaginatedResponse<T>(
    val data: List<T>,
    val total: Int,
    val page: Int,
    @SerialName("total_pages")
    val totalPages: Int
)

Retrofit Instance Setup

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideJson(): Json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
        encodeDefaults = true
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(
        authInterceptor: AuthInterceptor,
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG) {
                    HttpLoggingInterceptor.Level.BODY
                } else {
                    HttpLoggingInterceptor.Level.NONE
                }
            })
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()
    }

    @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 provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

Implementation Patterns

Authentication Interceptor

class AuthInterceptor @Inject constructor(
    private val tokenProvider: TokenProvider
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.accessToken
        val request = if (token != null) {
            chain.request().newBuilder()
                .addHeader("Authorization", "Bearer $token")
                .build()
        } else {
            chain.request()
        }
        return chain.proceed(request)
    }
}

Token Refresh with Authenticator

class TokenAuthenticator @Inject constructor(
    private val tokenProvider: TokenProvider,
    private val authApi: Lazy<AuthApiService>
) : Authenticator {

    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.retryCount >= 2) return null

        synchronized(this) {
            val newToken = runBlocking {
                authApi.get().refreshToken(
                    RefreshTokenRequest(tokenProvider.refreshToken ?: return@runBlocking null)
                )
            } ?: return null

            tokenProvider.updateTokens(newToken.accessToken, newToken.refreshToken)

            return response.request.newBuilder()
                .header("Authorization", "Bearer ${newToken.accessToken}")
                .build()
        }
    }

    private val Response.retryCount: Int
        get() = generateSequence(this) { it.priorResponse }.count() - 1
}

Sealed Result Wrapper

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
    data class Exception(val throwable: Throwable) : NetworkResult<Nothing>()
}

suspend fun <T> safeApiCall(apiCall: suspend () -> T): NetworkResult<T> {
    return try {
        NetworkResult.Success(apiCall())
    } catch (e: HttpException) {
        NetworkResult.Error(e.code(), e.message())
    } catch (e: IOException) {
        NetworkResult.Exception(e)
    }
}

// Usage
class UserRepository @Inject constructor(private val api: ApiService) {
    suspend fun getUser(id: Long): NetworkResult<User> = safeApiCall {
        api.getUser(id).toDomain()
    }
}

File Upload

suspend fun uploadAvatar(userId: Long, imageUri: Uri, context: Context) {
    val contentResolver = context.contentResolver
    val mimeType = contentResolver.getType(imageUri) ?: "image/jpeg"
    val inputStream = contentResolver.openInputStream(imageUri) ?: return
    val bytes = inputStream.use { it.readBytes() }

    val requestBody = bytes.toRequestBody(mimeType.toMediaType())
    val part = MultipartBody.Part.createFormData("avatar", "avatar.jpg", requestBody)

    apiService.uploadAvatar(userId, part)
}

Pagination with Paging 3

class UserPagingSource(
    private val api: ApiService,
    private val query: String
) : PagingSource<Int, User>() {

    override fun getRefreshKey(state: PagingState<Int, User>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        val page = params.key ?: 1
        return try {
            val response = api.searchUsers(query, page, params.loadSize)
            LoadResult.Page(
                data = response.data.map { it.toDomain() },
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (page >= response.totalPages) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

Best Practices

  • Use suspend functions in API interfaces for coroutine integration — avoid Call<T> wrappers.
  • Return Response<T> only when you need access to HTTP status codes or headers; otherwise return the body type directly.
  • Enable logging only in debug builds to avoid leaking sensitive data in production logs.
  • Use kotlinx.serialization with ignoreUnknownKeys = true so new server fields do not break the client.
  • Wrap API calls in a safeApiCall function that catches HttpException and IOException uniformly.
  • Set reasonable timeouts — 30 seconds for connect and read is a sensible default.
  • Use @Streaming for large file downloads to avoid loading the entire response into memory.

Common Pitfalls

  • Forgetting to add the kotlinx-serialization-converter or moshi-converter dependency, causing a runtime crash with no converter found.
  • Not handling HttpException (non-2xx responses) and IOException (network failures) separately.
  • Hardcoding base URLs instead of injecting them, making it impossible to switch between staging and production.
  • Using runBlocking inside interceptors on the main thread — the Authenticator and Interceptor run on OkHttp's threads, so runBlocking is safe there but dangerous elsewhere.
  • Logging sensitive headers (auth tokens) in production by leaving HttpLoggingInterceptor.Level.BODY on.
  • Not using @Streaming for large downloads, causing OutOfMemoryError.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →