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 type | When |
|---|---|
T | Throws 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
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
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
- 01
Build a typed error hierarchy
Define ApiResult + ApiError. Wrap one Retrofit interface's methods. Update one ViewModel to handle each error case.
- 02
Auth interceptor with refresh
Build AuthInterceptor that adds Authorization header and refreshes on 401. Use Mutex for thundering-herd protection.
- 03
Offline cache fallback
Add OfflineCacheInterceptor. Verify cached responses return when offline (force airplane mode).
- 04
Upload progress
Implement ProgressRequestBody. Display a progress bar in Compose while uploading a photo.
- 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.