Retrofit Essentials
Retrofit Essentials What Retrofit Is & Basic Setup Retrofit is a type-safe HTTP client for Android and Java/Kotlin JVM apps. Instead of hand-building HTTP reque…
Retrofit Essentials
What Retrofit Is & Basic Setup
Retrofit is a type-safe HTTP client for Android and Java/Kotlin JVM apps. Instead of hand-building HTTP requests, you declare an interface describing each endpoint — method, path, parameters, request/response bodies — and Retrofit generates the implementation at runtime using dynamic proxies, delegating the actual network work to OkHttp underneath.
A Retrofit instance is built once per base URL, wiring together the OkHttpClient (for connection pooling, interceptors, timeouts) and a converter factory (for turning JSON into Kotlin/Java objects and back). You then call create() with your API interface to get a working client — no manual request building or response parsing required.
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
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.addInterceptor(logging)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
val userApi = retrofit.create(UserApi::class.java)Interface Definitions & Annotations
Every endpoint is a method on an interface, annotated with its HTTP verb (@GET, @POST, @PUT, @PATCH, @DELETE) and relative path. Path segments in curly braces are filled by @Path-annotated parameters; @Query adds URL query parameters; @Body serializes an object as the request payload using the configured converter; @Header/@Headers attach request headers.
Suspend functions are the modern idiomatic style in Kotlin — Retrofit natively supports them, so a network call reads like sequential code and cancellation follows the calling coroutine's scope automatically. Returning a Response<T> instead of T gives you access to the raw HTTP status and headers alongside the parsed body when you need them.
interface UserApi {
@GET("users")
suspend fun listUsers(
@Query("page") page: Int,
@Query("limit") limit: Int = 20,
): UserPage
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: String): User
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): User
@PATCH("users/{id}")
suspend fun updateUser(
@Path("id") userId: String,
@Body request: UpdateUserRequest,
): Response<User>
@DELETE("users/{id}")
suspend fun deleteUser(@Path("id") userId: String): Response<Unit>
@Multipart
@POST("users/{id}/avatar")
suspend fun uploadAvatar(
@Path("id") userId: String,
@Part avatar: MultipartBody.Part,
): Response<Unit>
}
data class User(val id: String, val email: String, val name: String)
data class UserPage(val data: List<User>, val total: Int, val page: Int)
data class CreateUserRequest(val email: String, val name: String)
data class UpdateUserRequest(val name: String?)Converters & Interceptors
The converter factory determines how request/response bodies are serialized. Moshi and Gson are the two most common JSON converters — Moshi is generally preferred in Kotlin-first projects for its stricter null-safety and Kotlin codegen support, while Gson remains widely used in older or Java-heavy codebases. Only one converter factory is usually needed per Retrofit instance.
OkHttp interceptors run for every request/response and are the right place for cross-cutting concerns: attaching an auth token, retrying on a specific status code, or logging traffic. Application interceptors (added via addInterceptor) see the request before redirects/retries; network interceptors (addNetworkInterceptor) see the actual wire-level request and can observe redirects.
class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
val original = chain.request()
val token = tokenProvider() ?: return chain.proceed(original)
val authenticated = original.newBuilder()
.header("Authorization", "Bearer $token")
.build()
val response = chain.proceed(authenticated)
if (response.code == 401) {
// Token expired — a real implementation would refresh and retry once here.
response.close()
}
return response
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor { sessionStore.currentToken })
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.HEADERS })
.build()Coroutines & Error Handling
With suspend functions, a failed call throws rather than returning a special value — a network failure throws IOException, and a non-2xx HTTP response throws HttpException (unless the method returns Response<T>, which never throws for HTTP-level errors and instead exposes isSuccessful/code). Wrapping calls in a sealed Result type keeps calling code from scattering try/catch everywhere.
Older Java code or call sites that need manual thread control instead return a Call<T>, enqueued with a Callback — this predates coroutine support and still shows up in legacy codebases or pure-Java modules.
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int?, val message: String) : ApiResult<Nothing>()
}
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Success(block())
} catch (e: HttpException) {
ApiResult.Error(e.code(), e.response()?.errorBody()?.string() ?: e.message())
} catch (e: IOException) {
ApiResult.Error(null, "Network error: ${e.message}")
}
// Call site inside a ViewModel coroutine scope
viewModelScope.launch {
when (val result = safeApiCall { userApi.getUser(userId) }) {
is ApiResult.Success -> _uiState.value = UiState.Loaded(result.data)
is ApiResult.Error -> _uiState.value = UiState.Failed(result.message)
}
}// Legacy Java call site using Call<T> + Callback instead of a suspend function
Call<User> call = userApi.getUser(userId);
call.enqueue(new Callback<User>() {
@Override
public void onResponse(Call<User> call, Response<User> response) {
if (response.isSuccessful()) {
User user = response.body();
showUser(user);
} else {
showError("HTTP " + response.code());
}
}
@Override
public void onFailure(Call<User> call, Throwable t) {
showError("Network error: " + t.getMessage());
}
});Common Pitfalls
Forgetting the trailing slash on baseUrl() or a leading slash on an endpoint path — Retrofit resolves paths relative to baseUrl, and an unexpected leading slash silently strips the base path.
Reusing one OkHttpClient/Retrofit instance per request instead of building it once — connection pooling, DNS caching, and interceptor setup are meant to be shared across the app's lifetime.
Calling a suspend Retrofit method outside a coroutine scope tied to the right lifecycle — an unscoped GlobalScope launch can leak the request past the screen that started it.
Assuming a non-2xx response throws for every return type — it only throws for direct suspend-returning-T methods; a Response<T> return type never throws for HTTP errors and must be checked with isSuccessful.
Mismatched converter and response shape — a field renamed on the backend without an @Json/@SerializedName mapping fails silently (null field) rather than with a loud error, unless the converter is configured to fail on unknown/missing properties.
Logging full request/response bodies with HttpLoggingInterceptor.Level.BODY in production builds — this can leak tokens or PII into logs; restrict verbose logging to debug builds.