The OkHttp Stack
Every OkHttp request travels through a chain of interceptors before reaching the network. Understanding the order matters because where you add an interceptor determines what it sees.
Request →
[Application Interceptors] ← your custom auth, logging, retry
→ Cache Interceptor ← serves from cache or continues
→ Connect Interceptor ← establishes connection
→ [Network Interceptors] ← see actual wire data
→ Call Server Interceptor ← sends request, receives response
← Response
Connection Pooling
OkHttp maintains a pool of persistent HTTP/2 and HTTPS connections, reusing them across requests to the same host. By default: 5 connections, 5-minute keep-alive.
val client = OkHttpClient.Builder()
.connectionPool(ConnectionPool(
maxIdleConnections = 10,
keepAliveDuration = 10,
timeUnit = TimeUnit.MINUTES
))
.build()
You rarely need to configure this unless you're making many concurrent requests to the same host.
Application vs Network Interceptors
The distinction is crucial:
| Application Interceptors | Network Interceptors | |
|---|---|---|
| Added with | addInterceptor | addNetworkInterceptor |
| Called | Once per logical call | Once per physical network request |
| See cached responses | No | No (only called when network is used) |
| Can retry / short-circuit | Yes | No |
| Redirects | Sees final response | Sees each redirect |
| Best for | Auth, business logic, logging at app level | Wire-level logging, modifying headers for network |
Logging Interceptor
// gradle: implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
val loggingInterceptor = HttpLoggingInterceptor { message ->
Log.d("OkHttp", message)
}.apply {
// Never log bodies in production (PII risk)
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
Add the logging interceptor last so it sees the fully decorated request (auth headers added by earlier interceptors).
Auth Interceptor — Token Injection and 401 Refresh
The auth interceptor adds the access token to every request and handles token refresh on 401:
class AuthInterceptor @Inject constructor(
private val tokenStore: TokenStore,
private val authApi: AuthApi // separate OkHttp client, no auth interceptor
) : Interceptor {
private val mutex = Mutex() // prevent concurrent refresh races
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStore.getAccessToken()
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
val response = chain.proceed(request)
if (response.code != 401) return response
// Refresh token — serialize with mutex to prevent multiple simultaneous refreshes
return runBlocking {
mutex.withLock {
val currentToken = tokenStore.getAccessToken()
// Another coroutine may have already refreshed while we waited for the lock
if (currentToken != token) {
response.close()
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
)
} else {
try {
val newToken = authApi.refreshToken(tokenStore.getRefreshToken())
tokenStore.saveTokens(newToken.accessToken, newToken.refreshToken)
response.close()
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer ${newToken.accessToken}")
.build()
)
} catch (e: Exception) {
// Refresh failed — user must log in again
tokenStore.clearTokens()
response // return 401 to the caller
}
}
}
}
}
}
Use a separate OkHttpClient for the refresh call that does not include the AuthInterceptor — otherwise you get infinite 401 recursion.
OkHttp Caching
OkHttp respects HTTP cache headers (Cache-Control, ETag, Last-Modified) and stores responses on disk.
val cacheDir = File(context.cacheDir, "http_cache")
val cache = Cache(cacheDir, maxSize = 50L * 1024L * 1024L) // 50 MB
val client = OkHttpClient.Builder()
.cache(cache)
.addInterceptor(offlineCacheInterceptor) // application: runs before cache check
.addNetworkInterceptor(onlineCacheInterceptor) // network: runs after server response
.build()
Offline cache — serve stale data when no network
val offlineCacheInterceptor = Interceptor { chain ->
var request = chain.request()
if (!isNetworkAvailable(context)) {
// Tell OkHttp: serve cached data up to 7 days old
request = request.newBuilder()
.cacheControl(CacheControl.Builder()
.maxStale(7, TimeUnit.DAYS)
.onlyIfCached()
.build())
.build()
}
chain.proceed(request)
}
Online cache — override server headers for shorter TTL
val onlineCacheInterceptor = Interceptor { chain ->
val response = chain.proceed(chain.request())
val cacheControl = CacheControl.Builder()
.maxAge(5, TimeUnit.MINUTES) // cache for 5 minutes
.build()
response.newBuilder()
.removeHeader("Pragma")
.removeHeader("Cache-Control")
.header("Cache-Control", cacheControl.toString())
.build()
}
Cache-then-network strategy (for instant UI + fresh data)
suspend fun getProducts(): Flow<List<Product>> = flow {
// Emit from cache immediately
val cached = localDb.getProducts()
if (cached.isNotEmpty()) emit(cached)
// Fetch fresh from network
try {
val fresh = api.getProducts()
localDb.saveProducts(fresh)
emit(localDb.getProducts())
} catch (e: IOException) {
if (cached.isEmpty()) throw e // nothing to show
// Swallow if we already emitted cached data
}
}
Retry Interceptor
class RetryInterceptor(private val maxRetries: Int = 3) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var response: Response? = null
var lastException: IOException? = null
for (attempt in 0 until maxRetries) {
try {
response?.close()
response = chain.proceed(request)
if (response.isSuccessful || response.code in 400..499) {
// Don't retry client errors
return response
}
// Server error — wait and retry
if (attempt < maxRetries - 1) {
response.close()
Thread.sleep(backoffMs(attempt))
}
} catch (e: IOException) {
lastException = e
if (attempt < maxRetries - 1) {
Thread.sleep(backoffMs(attempt))
}
}
}
return response ?: throw lastException ?: IOException("Max retries reached")
}
private fun backoffMs(attempt: Int): Long {
val base = 1000L * (2.0.pow(attempt)).toLong()
val jitter = (Math.random() * 500).toLong()
return minOf(base + jitter, 30_000L)
}
}
Retrofit Configuration
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
context: Context
): OkHttpClient {
val cacheDir = File(context.cacheDir, "http_cache")
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(RetryInterceptor(maxRetries = 3))
.addInterceptor(
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
)
.cache(Cache(cacheDir, 50L * 1024 * 1024))
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
)
)
.build()
}
}
Retrofit annotations reference
interface ProductApi {
// Path parameter
@GET("products/{id}")
suspend fun getProduct(@Path("id") productId: String): Product
// Query parameters
@GET("products")
suspend fun listProducts(
@Query("category") category: String?,
@Query("limit") limit: Int = 20
): List<Product>
// Custom headers
@Headers("X-Platform: Android", "Accept-Language: en")
@GET("products/featured")
suspend fun getFeatured(): List<Product>
// Dynamic header
@GET("orders")
suspend fun getOrders(
@Header("X-Request-ID") requestId: String
): List<Order>
// Request body
@POST("orders")
suspend fun createOrder(@Body request: CreateOrderRequest): Order
// Form-encoded
@FormUrlEncoded
@POST("auth/token")
suspend fun refreshToken(
@Field("grant_type") grantType: String = "refresh_token",
@Field("refresh_token") refreshToken: String
): TokenResponse
// Multipart file upload
@Multipart
@POST("users/avatar")
suspend fun uploadAvatar(
@Part avatar: MultipartBody.Part,
@Part("description") description: RequestBody
): AvatarResponse
// Streaming response
@Streaming
@GET
suspend fun downloadFile(@Url url: String): ResponseBody
}
Multipart file upload helper
fun createImagePart(file: File): MultipartBody.Part {
val requestBody = file.asRequestBody("image/jpeg".toMediaType())
return MultipartBody.Part.createFormData("avatar", file.name, requestBody)
}
fun createTextPart(text: String): RequestBody =
text.toRequestBody("text/plain".toMediaType())
Timeout Configuration
// Timeouts for different scenarios
val uploadClient = baseClient.newBuilder()
.writeTimeout(120, TimeUnit.SECONDS) // large file upload
.readTimeout(120, TimeUnit.SECONDS)
.build()
val streamClient = baseClient.newBuilder()
.readTimeout(0, TimeUnit.SECONDS) // 0 = infinite (for streaming)
.build()
// Per-call timeout (OkHttp 3.12+)
val request = Request.Builder()
.url("https://api.example.com/slow-endpoint")
.tag(Timeout::class.java, Timeout().timeout(45, TimeUnit.SECONDS))
.build()
Key Takeaways
| Concept | Key Point |
|---|---|
| Application interceptors | Business logic, auth, retry; addInterceptor |
| Network interceptors | Wire-level modifications; addNetworkInterceptor |
| Auth interceptor | Inject token; mutex prevents concurrent refresh races |
| OkHttp cache | Disk-backed; respects Cache-Control; configure per environment |
| Offline strategy | maxStale + onlyIfCached for airplane mode |
| Retry interceptor | Exponential backoff; only retry 5xx and IOException |
| Logging interceptor | BODY in debug; NONE in release |
| Timeouts | 15s connect, 30s read/write; 0 for streaming |