androidengineers.Book a session

Network Architecture

OkHttp/Retrofit: Interceptors & Caching

article25 minHard

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 InterceptorsNetwork Interceptors
Added withaddInterceptoraddNetworkInterceptor
CalledOnce per logical callOnce per physical network request
See cached responsesNoNo (only called when network is used)
Can retry / short-circuitYesNo
RedirectsSees final responseSees each redirect
Best forAuth, business logic, logging at app levelWire-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

ConceptKey Point
Application interceptorsBusiness logic, auth, retry; addInterceptor
Network interceptorsWire-level modifications; addNetworkInterceptor
Auth interceptorInject token; mutex prevents concurrent refresh races
OkHttp cacheDisk-backed; respects Cache-Control; configure per environment
Offline strategymaxStale + onlyIfCached for airplane mode
Retry interceptorExponential backoff; only retry 5xx and IOException
Logging interceptorBODY in debug; NONE in release
Timeouts15s connect, 30s read/write; 0 for streaming

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
OkHttp/Retrofit: Interceptors & Caching | Android System Design | Android Engineers