OkHttp & Interceptors Explained

DATA › Networking

**OkHttp is the HTTP engine underneath Retrofit**, and interceptors are how you hook into the request or response pipeline without touching every call site. An interceptor is just a function that sees a request, can inspect or modify it, calls chain.proceed() to continue the chain, and can inspect or modify the response coming back:

val loggingInterceptor = Interceptor { chain ->
    val request = chain.request()
    val response = chain.proceed(request)
    response
}

There are two flavors, **application interceptors** (addInterceptor) and **network interceptors** (addNetworkInterceptor), and the whole topic is understanding what each one sees and how many times it runs.

**Application interceptors run exactly once per logical call**, no matter how many redirects or retries happen underneath. They see your original request before OkHttp adds its own headers, and they never see a cache hit's internals, from their point of view it's just 'call in, response out'. Request is immutable, so to add a header you build a modified copy:

val authInterceptor = Interceptor { chain ->
    val authed = chain.request()
        .newBuilder()
        .header("Authorization", "Bearer $token")
        .build()
    chain.proceed(authed)
}

val client = OkHttpClient.Builder()
    .addInterceptor(authInterceptor)
    .build()

This is the right layer for app-level auth headers, they should apply once per call, not once per redirect.

**Network interceptors run once per actual network request**, which means they can fire zero times (served from cache) or multiple times (a redirect or an auth retry re-runs the network leg). They see the request *after* OkHttp injects its own headers like Accept-Encoding, and they have access to the Connection for TLS or IP details, something application interceptors can't see:

val client = OkHttpClient.Builder()
    .addInterceptor(appInterceptor)          // once per call
    .addNetworkInterceptor(networkInterceptor) // once per network hop, 0..N times
    .build()

This is why HttpLoggingInterceptor is usually added as an application interceptor, one clean log line per call, instead of a network one, which would log every redirect and the raw encoded bytes.

**Multiple interceptors of the same type form an ordered chain.** The request travels through them in registration order, and the response unwinds in reverse:

val client = OkHttpClient.Builder()
    .addInterceptor(interceptorA)  // registered first
    .addInterceptor(interceptorB)
    .build()

// request:  A -> B -> [network]
// response: [network] -> B -> A

Every application interceptor's chain.proceed() call also sits above every network interceptor and the actual network code, so if you needed a single mental picture: application interceptors form an outer shell, network interceptors sit just inside the wire.

**Auth tokens have two different jobs, and two different mechanisms.** An **interceptor** proactively attaches a header to every outgoing request. An **Authenticator** reacts *after* the server responds 401 (or 407 for a proxy), and returns a new Request to retry, or null to give up:

val client = OkHttpClient.Builder()
    .authenticator { _, response ->
        if (response.request.header("Authorization") != null) return@authenticator null // already retried, give up
        val newToken = tokenStore.refresh()
        response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }
    .build()

Use an interceptor to attach the current token proactively, and an Authenticator specifically to refresh and retry after a 401, checking the prior request to avoid an infinite refresh loop.

**Response caching is opt-in and header-driven.** Configure a Cache on the builder, and OkHttp honors standard Cache-Control, ETag, and Last-Modified headers to decide what's servable and when it needs revalidation:

val client = OkHttpClient.Builder()
    .cache(Cache(cacheDir, 10L * 1024 * 1024)) // 10 MiB
    .build()

To serve stale data when offline, an interceptor can rewrite the request's Cache-Control to force a cache read:

val offlineInterceptor = Interceptor { chain ->
    var request = chain.request()
    if (!isNetworkAvailable()) {
        request = request.newBuilder()
            .cacheControl(CacheControl.FORCE_CACHE)
            .build()
    }
    chain.proceed(request)
}

**Share one OkHttpClient instance across your whole app.** It owns the ConnectionPool (reused keep-alive connections to the same host, roughly 5 idle connections for 5 minutes by default), the Dispatcher's thread pools, and the Cache. Building a new client per request throws all of that reuse away and pays a fresh TCP or TLS handshake every time.

Timeouts are also configured on the builder, and each covers a different phase:

val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)  // TCP/TLS handshake
    .readTimeout(10, TimeUnit.SECONDS)     // idle gap waiting for bytes
    .writeTimeout(10, TimeUnit.SECONDS)    // idle gap sending bytes
    .callTimeout(30, TimeUnit.SECONDS)     // whole call incl. redirects; 0 = no limit
    .build()

**Two more knobs worth knowing.** CertificatePinner ties a hostname to expected certificate public-key hashes, so the TLS handshake fails if a rogue CA issues a cert for your domain, it's set on the builder, not per-request, and needs a backup pin plus a rotation plan since a wrong pin bricks all networking:

val pinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAA...=")
    .build()
OkHttpClient.Builder().certificatePinner(pinner)

And gzip is asymmetric: OkHttp transparently decompresses gzipped **responses** for free, but it never compresses **request** bodies automatically, that needs your own interceptor setting Content-Encoding: gzip and wrapping the body.

Back to OkHttp & Interceptors