Retrofit
Retrofit HTTP client for type-safe REST API communication in Android with Kotlin coroutines
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 linesRetrofit 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:
HttpExceptionmeans the server responded with a non-2xx status code (a server-side problem).IOExceptionmeans 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
NONEor at mostBASICin 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
runBlockingis safe there. But if you accidentally invoke an interceptor synchronously from the main thread (rare but possible with misconfigured clients),runBlockingcauses 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 causesOutOfMemoryError. The@Streamingannotation returns aResponseBodythat 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
suspendfunctions in API interfaces for coroutine integration — avoidCall<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.serializationwithignoreUnknownKeys = trueso new server fields do not break the client. - Wrap API calls in a
safeApiCallfunction that catchesHttpExceptionandIOExceptionuniformly. - Set reasonable timeouts — 30 seconds for connect and read is a sensible default.
- Use
@Streamingfor large file downloads to avoid loading the entire response into memory.
Common Pitfalls
- Forgetting to add the
kotlinx-serialization-converterormoshi-converterdependency, causing a runtime crash with no converter found. - Not handling
HttpException(non-2xx responses) andIOException(network failures) separately. - Hardcoding base URLs instead of injecting them, making it impossible to switch between staging and production.
- Using
runBlockinginside interceptors on the main thread — theAuthenticatorandInterceptorrun on OkHttp's threads, sorunBlockingis safe there but dangerous elsewhere. - Logging sensitive headers (auth tokens) in production by leaving
HttpLoggingInterceptor.Level.BODYon. - Not using
@Streamingfor large downloads, causingOutOfMemoryError.
Install this skill directly: skilldb add android-kotlin-skills
Related Skills
Architecture
MVVM architecture pattern with ViewModel, LiveData, and StateFlow for scalable Android apps
Coroutines
Kotlin coroutines and Flow for asynchronous programming and reactive streams in Android
Dependency Injection
Hilt and Dagger dependency injection for managing object creation and scoping in Android apps
Jetpack Compose
Jetpack Compose declarative UI toolkit for building native Android interfaces with Kotlin
Navigation
Jetpack Navigation component for type-safe in-app navigation and deep linking in Android
Room Database
Room persistence library for local SQLite database access with compile-time query verification in Android