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.