All topics
Mobile · Learning hub

Retrofit notes for developers

Master Retrofit with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — Retrofit quizMore Mobile notes
Retrofit

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.

Keep your Retrofit knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever