Skip to main content

Retrofit & OkHttp Deep Dive

The networking overview introduced Retrofit + OkHttp. This chapter goes deep on the patterns every production app needs: typed error handling, auth + token refresh, response caching, retry with backoff, certificate pinning, and custom interceptors.

Setup — the production-grade configuration

// libs.versions.toml
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"

retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {

@Provides @Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.add(Date::class.java, Rfc3339DateJsonAdapter().nullSafe())
.build()

@Provides @Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
loggingInterceptor: HttpLoggingInterceptor,
@ApplicationContext context: Context
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.addInterceptor(RateLimitInterceptor())
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB
.connectTimeout(10.seconds.toJavaDuration())
.readTimeout(30.seconds.toJavaDuration())
.writeTimeout(30.seconds.toJavaDuration())
.certificatePinner(buildPinner())
.build()

@Provides @Singleton
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()

@Provides @Singleton
fun provideHttpLogging(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}

private fun buildPinner(): CertificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAA...")
.add("api.example.com", "sha256/BBBB...") // backup pin
.build()
}

Retrofit interfaces — modern patterns

Suspend functions, not callbacks

interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): UserDto

@GET("users")
suspend fun searchUsers(@Query("q") query: String, @Query("limit") limit: Int = 20): UsersResponseDto

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

@PATCH("users/{id}")
suspend fun updateUser(
@Path("id") id: String,
@Body patch: UserPatchRequest
): UserDto

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

@Multipart
@POST("users/{id}/avatar")
suspend fun uploadAvatar(
@Path("id") id: String,
@Part avatar: MultipartBody.Part
): UploadResponseDto
}

suspend is the only API shape needed in 2025. No more Call<T>, no more Single<T>, no callbacks.

Headers — per-method and dynamic

interface UserApi {
// Static header
@Headers("Cache-Control: max-age=300")
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): UserDto

// Dynamic header per call
@GET("users/{id}")
suspend fun getUser(
@Path("id") id: String,
@Header("X-Trace-Id") traceId: String,
@HeaderMap extraHeaders: Map<String, String> = emptyMap()
): UserDto
}

For app-wide headers (Authorization, User-Agent), use an interceptor — not per-method @Header.

Body types

// JSON body (default with Moshi converter)
@POST("users")
suspend fun createUser(@Body req: CreateUserRequest): UserDto

// Form URL encoded
@FormUrlEncoded
@POST("oauth/token")
suspend fun refreshToken(
@Field("grant_type") grantType: String = "refresh_token",
@Field("refresh_token") refreshToken: String
): TokenResponse

// Multipart (file upload)
@Multipart
@POST("uploads")
suspend fun upload(
@Part("description") description: RequestBody,
@Part file: MultipartBody.Part
): UploadResponse

// Raw body
@POST("graphql")
suspend fun graphql(@Body query: RequestBody): ResponseBody

Return types

Return typeWhen
TThrows on non-2xx; convenient
Response<T>Access status code + headers; check isSuccessful
Result<T> (Kotlin stdlib)Wrap throws in a Result
Custom ApiResult<T, E>Typed errors (recommended)
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): Response<UserDto>

// Usage
val response = api.getUser("u1")
if (response.isSuccessful) {
val user = response.body()!!
} else {
val errorBody = response.errorBody()?.string()
val code = response.code()
}

For typed errors, see the next section.


Typed error handling

sealed interface ApiResult<out T, out E> {
data class Success<T>(val data: T) : ApiResult<T, Nothing>
data class Failure<E>(val error: E, val cause: Throwable? = null) : ApiResult<Nothing, E>
}

sealed interface ApiError {
data object Network : ApiError
data object Timeout : ApiError
data object Unauthorized : ApiError
data object Forbidden : ApiError
data object NotFound : ApiError
data class ServerError(val code: Int) : ApiError
data class ClientError(val code: Int, val body: String?) : ApiError
data class Decoding(val message: String) : ApiError
data class Unknown(val message: String?) : ApiError
}

suspend inline fun <T> apiCall(crossinline block: suspend () -> T): ApiResult<T, ApiError> = try {
ApiResult.Success(block())
} catch (c: CancellationException) {
throw c
} catch (e: HttpException) {
val error = when (e.code()) {
401 -> ApiError.Unauthorized
403 -> ApiError.Forbidden
404 -> ApiError.NotFound
in 400..499 -> ApiError.ClientError(e.code(), e.response()?.errorBody()?.string())
in 500..599 -> ApiError.ServerError(e.code())
else -> ApiError.Unknown(e.message)
}
ApiResult.Failure(error, e)
} catch (e: SocketTimeoutException) {
ApiResult.Failure(ApiError.Timeout, e)
} catch (e: IOException) {
ApiResult.Failure(ApiError.Network, e)
} catch (e: JsonDataException) {
ApiResult.Failure(ApiError.Decoding(e.message ?: "Decoding error"), e)
} catch (e: Throwable) {
ApiResult.Failure(ApiError.Unknown(e.message), e)
}

// Usage
suspend fun getUser(id: String): ApiResult<User, ApiError> = apiCall {
api.getUser(id).toDomain()
}

when (val result = getUser("u1")) {
is ApiResult.Success -> updateUi(result.data)
is ApiResult.Failure -> when (result.error) {
ApiError.Unauthorized -> router.signOut()
ApiError.Network -> showOfflineBanner()
else -> showGenericError()
}
}

Typed errors give callers compile-time-exhaustive handling — no "forgot to handle 403" bugs.


Auth interceptor with token refresh

@Singleton
class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore,
private val authApi: Provider<AuthApi> // Provider to avoid circular dep
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()

// Skip auth for explicitly public endpoints
if (request.header(NO_AUTH) != null) {
return chain.proceed(request.newBuilder().removeHeader(NO_AUTH).build())
}

val accessToken = runBlocking { tokenStore.accessToken() }

val authed = if (accessToken != null) {
request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
} else request

val response = chain.proceed(authed)

// On 401, try refresh once
if (response.code == 401 && accessToken != null) {
response.close()
val newToken = runBlocking { refreshToken() } ?: return chain.proceed(authed)

return chain.proceed(request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build())
}

return response
}

private suspend fun refreshToken(): String? = tokenRefreshMutex.withLock {
val refresh = tokenStore.refreshToken() ?: return@withLock null
try {
val response = authApi.get().refreshToken(refresh)
tokenStore.update(response.accessToken, response.refreshToken)
response.accessToken
} catch (e: Exception) {
tokenStore.clear()
null
}
}

companion object {
const val NO_AUTH = "No-Auth"
private val tokenRefreshMutex = Mutex()
}
}

// Skip auth for specific endpoints
interface AuthApi {
@Headers("${AuthInterceptor.NO_AUTH}: true")
@POST("auth/refresh")
suspend fun refreshToken(@Body request: RefreshRequest): TokenResponse
}

The Mutex prevents the thundering herd — if 5 simultaneous requests all get 401, only one refresh happens; the others wait.


Response caching

OkHttp has a built-in HTTP cache that respects Cache-Control, ETag, Last-Modified:

.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024))

Server-side Cache-Control

The server sends Cache-Control: max-age=300. OkHttp caches the response for 300 seconds. Subsequent calls within that window return the cached copy without hitting the network.

Client-side cache override

When the server doesn't set Cache-Control but you want to cache:

class CacheOverrideInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
return response.newBuilder()
.header("Cache-Control", "public, max-age=300")
.removeHeader("Pragma")
.build()
}
}

// Apply only to GET requests
OkHttpClient.Builder()
.addNetworkInterceptor(CacheOverrideInterceptor()) // network interceptor — after cache check
.build()

Application vs Network interceptors:

  • Application interceptors see every request, even cached ones
  • Network interceptors see only requests that actually hit the network

For cache-mutation, use a network interceptor. For auth (must apply to every request), use an application interceptor.

Offline cache fallback

class OfflineCacheInterceptor @Inject constructor(
private val connectivity: ConnectivityObserver
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (!connectivity.isOnline()) {
request = request.newBuilder()
.header("Cache-Control", "public, only-if-cached, max-stale=${60 * 60 * 24 * 7}")
.build()
}
return chain.proceed(request)
}
}

When offline, OkHttp returns cached responses up to 7 days stale. Critical for offline-tolerant UIs (news feeds, product catalogs).


Retry with exponential backoff

OkHttp doesn't retry application-level errors. Add an interceptor:

class RetryInterceptor(
private val maxRetries: Int = 3,
private val initialDelayMs: Long = 1000,
private val factor: Double = 2.0,
private val jitter: Double = 0.3
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var attempt = 0
var delay = initialDelayMs

while (true) {
try {
val response = chain.proceed(request)

// Don't retry success or non-retryable errors
if (response.isSuccessful || response.code !in setOf(429, 500, 502, 503, 504)) {
return response
}

response.close()
} catch (e: SocketTimeoutException) {
if (attempt >= maxRetries) throw e
} catch (e: IOException) {
if (attempt >= maxRetries) throw e
}

// Honor server Retry-After if present
val retryAfter = (chain.call().request().header("Retry-After")?.toLongOrNull() ?: 0) * 1000
val jitterMs = (delay * jitter * (Math.random() - 0.5)).toLong()
Thread.sleep(maxOf(retryAfter, delay + jitterMs))

attempt++
if (attempt > maxRetries) {
return chain.proceed(request) // last attempt; let exceptions propagate
}
delay = (delay * factor).toLong()
}
}
}

Add only for the idempotent API (GET); never retry POST without careful idempotency-key design.

For mutation safety, see Module 06: GraphQL/WebSocket/gRPC and Module 04: Offline-First for idempotency keys.


Rate limiting

For client-side rate limiting (especially for analytics endpoints):

class RateLimitInterceptor(
private val maxRequestsPerSecond: Int = 10
) : Interceptor {
private val window = ArrayDeque<Long>()
private val mutex = Any()

override fun intercept(chain: Interceptor.Chain): Response {
synchronized(mutex) {
val now = System.currentTimeMillis()
while (window.isNotEmpty() && window.first() < now - 1000) {
window.removeFirst()
}
if (window.size >= maxRequestsPerSecond) {
val sleepMs = 1000 - (now - window.first())
Thread.sleep(sleepMs)
}
window.addLast(System.currentTimeMillis())
}
return chain.proceed(chain.request())
}
}

Prevents accidentally hammering an endpoint (e.g., analytics events on every scroll).


File upload with progress

class ProgressRequestBody(
private val delegate: RequestBody,
private val onProgress: (bytesWritten: Long, contentLength: Long) -> Unit
) : RequestBody() {

override fun contentType() = delegate.contentType()
override fun contentLength() = delegate.contentLength()

override fun writeTo(sink: BufferedSink) {
val countingSink = CountingSink(sink, contentLength(), onProgress).buffer()
delegate.writeTo(countingSink)
countingSink.flush()
}

private class CountingSink(
delegate: Sink,
private val total: Long,
private val onProgress: (Long, Long) -> Unit
) : ForwardingSink(delegate) {
private var written = 0L

override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
written += byteCount
onProgress(written, total)
}
}
}

// Usage
val file = File(filePath)
val requestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val progressBody = ProgressRequestBody(requestBody) { written, total ->
val percent = (written * 100 / total).toInt()
onProgressUpdate(percent)
}

val multipart = MultipartBody.Part.createFormData("file", file.name, progressBody)
val response = api.uploadAvatar(userId, multipart)

Same pattern for downloads — ProgressResponseBody wrapping the response.


Streaming responses

For large responses (CSV exports, server-sent events), don't buffer:

@GET("users/export")
@Streaming
suspend fun exportUsers(): ResponseBody

// Usage
api.exportUsers().byteStream().use { input ->
File(targetFile).outputStream().use { output ->
input.copyTo(output, bufferSize = 8192)
}
}

@Streaming prevents OkHttp from buffering the entire response in memory. Critical for files > a few MB.


Network testing with MockWebServer

// build.gradle
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
class UserApiTest {
private lateinit var server: MockWebServer
private lateinit var api: UserApi

@Before fun setup() {
server = MockWebServer().apply { start() }
api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(UserApi::class.java)
}

@After fun teardown() { server.shutdown() }

@Test fun `fetches user by ID`() = runTest {
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""{"id":"u1","name":"Aarav","email":"a@x.com"}""")
)

val user = api.getUser("u1")

assertEquals("Aarav", user.name)

val request = server.takeRequest()
assertEquals("GET", request.method)
assertEquals("/users/u1", request.path)
}

@Test fun `handles 401 with token refresh`() = runTest {
server.enqueue(MockResponse().setResponseCode(401)) // first call
server.enqueue(MockResponse().setResponseCode(200).setBody(refreshTokenResponse)) // refresh
server.enqueue(MockResponse().setResponseCode(200).setBody(userResponse)) // retry

val user = api.getUser("u1")
assertNotNull(user)
assertEquals(3, server.requestCount)
}
}

MockWebServer runs a real HTTP server on localhost — tests the full OkHttp pipeline including interceptors.


Custom converter factory

For non-standard response wrappers:

{
"data": { "id": "u1", "name": "Aarav" },
"meta": { "rateLimit": 100, "remaining": 99 }
}
class EnvelopeConverterFactory(
private val delegate: Converter.Factory
) : Converter.Factory() {
override fun responseBodyConverter(
type: Type, annotations: Array<Annotation>, retrofit: Retrofit
): Converter<ResponseBody, *>? {
val envelopeType = Types.newParameterizedType(Envelope::class.java, type)
val envelopeConverter = retrofit.nextResponseBodyConverter<Envelope<Any>>(
this, envelopeType, annotations
)
return Converter<ResponseBody, Any> { body ->
envelopeConverter.convert(body)?.data
}
}
}

data class Envelope<T>(val data: T?, val meta: Meta?)

// Usage
.addConverterFactory(EnvelopeConverterFactory(MoshiConverterFactory.create(moshi)))

Now api.getUser("u1") returns User directly, not Envelope<User>.


CallAdapter — custom return types

For typed errors at the Retrofit layer:

sealed interface ApiResult<out T, out E> {
data class Success<T>(val data: T) : ApiResult<T, Nothing>
data class Failure<E>(val error: E) : ApiResult<Nothing, E>
}

class ApiResultCallAdapterFactory : CallAdapter.Factory() {
override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
if (getRawType(returnType) != ApiResult::class.java) return null
// Implementation: wrap suspend call result in ApiResult.Success / Failure
// (Full implementation omitted for brevity — see Skydoves' sandwich library)
return null
}
}

For production, use a library like sandwich instead of rolling your own — it gives you ApiResult<T, E> returns natively.


Common anti-patterns

Anti-patterns

Networking smells

  • New OkHttpClient per request (no connection pooling)
  • Retry on every status code (idempotency hazard)
  • Hardcoded base URL in production code
  • Logging body in release builds (leaks PII)
  • No timeout configuration (hangs forever)
  • Catching all exceptions and ignoring
Best practices

Production networking

  • One @Singleton OkHttpClient; share via Hilt
  • Retry only 429 / 5xx + GETs (idempotent)
  • BuildConfig.BASE_URL per flavor
  • HttpLoggingInterceptor.Level.NONE in release
  • connect/read/write timeouts configured
  • Typed errors via sealed ApiError hierarchy

Key takeaways

Practice exercises

  1. 01

    Build a typed error hierarchy

    Define ApiResult + ApiError. Wrap one Retrofit interface's methods. Update one ViewModel to handle each error case.

  2. 02

    Auth interceptor with refresh

    Build AuthInterceptor that adds Authorization header and refreshes on 401. Use Mutex for thundering-herd protection.

  3. 03

    Offline cache fallback

    Add OfflineCacheInterceptor. Verify cached responses return when offline (force airplane mode).

  4. 04

    Upload progress

    Implement ProgressRequestBody. Display a progress bar in Compose while uploading a photo.

  5. 05

    MockWebServer integration test

    Write a test that queues 3 responses (401, refresh success, 200) and verifies the auth interceptor refreshes correctly.

Next

Continue to Paging 3 Deep Dive for paginated REST and local-first lists.