OkHttp & Interceptors Explained
DATA › Networking
OkHttp is the HTTP engine underneath Retrofit, and almost everything Android networking does at runtime flows through it. The way you hook into that pipeline, without touching every call site in your app, is an interceptor. An interceptor is a function that receives a request, can inspect or change it, calls chain.proceed() to hand it onward, and then gets to inspect or change the response that comes back. There are two kinds, application interceptors and network interceptors, and almost every interview question about OkHttp comes down to what each one is allowed to see and how many times it runs, which later chunks unpack in detail. First, a rule that applies to both kinds equally: Request objects are immutable. You cannot reach into a request and set a header on it. Instead you call newBuilder() on the request you were handed, add the header to that builder, build a new request from it, and proceed with the new copy. This immutability is why every interceptor that adds a header looks the same shape.
val authInterceptor = Interceptor { chain ->
val authedRequest = chain.request()
.newBuilder()
.header("Authorization", "Bearer $token")
.build()
chain.proceed(authedRequest)
}
Application interceptors, the ones you add with addInterceptor, run exactly once per logical call, no matter what happens underneath. If the server redirects you twice, or OkHttp retries a dropped connection, or the response comes straight out of the cache without touching the network at all, your application interceptor still only fires once. From its point of view, it's just call in, response out. That also means an application interceptor sees your original request exactly as you built it, before OkHttp adds any of its own headers, and it has no visibility into the underlying connection. This is the layer where you want anything that should apply once per logical request, like attaching the current auth token or writing one log line per call.
Network interceptors, added with addNetworkInterceptor, run once per actual network request instead of once per call. That means a redirect or an auth retry can make a network interceptor fire multiple times for what the caller experiences as a single request, and a response served entirely from cache means it doesn't fire at all. Because a network interceptor sits closer to the wire, it sees the request after OkHttp has already added its own headers. The clearest example is Accept-Encoding: if you never set that header yourself, OkHttp quietly adds Accept-Encoding: gzip before the request goes out, and a network interceptor observes that header. An application interceptor, running earlier in the pipeline, never sees it. Network interceptors also get access to the underlying Connection object, which exposes things like the negotiated TLS version and cipher, something application interceptors cannot reach.
val client = OkHttpClient.Builder()
.addInterceptor(appInterceptor) // once per call
.addNetworkInterceptor(networkInterceptor) // once per network hop, 0..N times
.build()
When you register more than one interceptor of the same kind, they form an ordered chain, and the order you add them in matters. Say you call addInterceptor with interceptor A first, then interceptor B. The outgoing request passes through A, then B, then out to the network. The response comes back the other way: it unwinds through B first, then A. Every application interceptor's call to chain.proceed() also sits above every network interceptor, and above the actual network code underneath that, so the mental picture is layers: application interceptors form an outer shell, network interceptors sit just inside the wire, and the raw connection is innermost.
val client = OkHttpClient.Builder()
.addInterceptor(interceptorA) // registered first
.addInterceptor(interceptorB)
.build()
// request: A -> B -> [network]
// response: [network] -> B -> A
A ResponseBody in OkHttp is a one-shot stream, not a value you can read twice. If an interceptor calls response.body's string method to peek at the payload, that call fully reads and closes the underlying stream. If that same interceptor then returns the original response object up to the caller, the caller gets a response whose body is already closed or empty, because the bytes are gone. This is a classic bug when someone adds a debug interceptor that logs the body and forwards the response unchanged. The fix is either peekBody, which lets you read a snapshot without consuming the real stream, or manually rebuilding the Response with a fresh ResponseBody constructed from the bytes you already read.
val response = chain.proceed(chain.request())
val peeked = response.peekBody(Long.MAX_VALUE)
println(peeked.string())
return response // original body stream is still open for the caller
Attaching a token and refreshing an expired one are two different jobs, and OkHttp gives you two different mechanisms for them. An interceptor is proactive: it attaches the current token to every outgoing request before anything is sent. An Authenticator is reactive: OkHttp calls it only after the server responds with 401, or 407 for a proxy, and it returns a new Request to retry with, or null to give up and let the 401 through. Wire it up with the authenticator function on the builder. Inside it, refresh the token and build a retried request from the failed one's request. The classic mistake is not guarding against an infinite loop: if the refreshed token is also rejected, OkHttp will call your Authenticator again on the new 401, so check whether the prior request already carried an Authorization header and return null if it did.
val client = OkHttpClient.Builder()
.authenticator { _, response ->
if (response.request.header("Authorization") != null) return@authenticator null
val newToken = tokenStore.refresh()
response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
}
.build()
HttpLoggingInterceptor is usually added as an application interceptor rather than a network one, on purpose. As an application interceptor it logs one clean line per logical call, showing the request you actually built and the final response, without noise from every redirect. As a network interceptor it would log every hop separately, including raw and possibly gzip-encoded bytes, which is far noisier and harder to read. The bigger issue is the level you choose. Level.BODY writes full headers and full request and response bodies to a place any app or a connected debugger on the device can potentially read. That includes bearer tokens, cookies, and any personal data in the payload. Restrict BODY to debug builds only, and call redactHeader for anything sensitive you must keep, like Authorization or Cookie, so even debug logs don't leak it.
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
redactHeader("Authorization")
redactHeader("Cookie")
}
Response caching in OkHttp is opt-in and driven by standard HTTP headers. You configure a Cache with a directory and a max size on the builder, and from then on OkHttp honors Cache-Control, ETag, and Last-Modified the way the HTTP caching spec defines, deciding what's servable and when it needs to revalidate with the server. That default behavior still requires network reachability to check freshness. To serve a stored response while the device is offline, even if the server marked it stale, you write an interceptor that rewrites the outgoing request's Cache-Control before it proceeds, typically forcing a cache-only style directive when you detect no connectivity. When the device is online you leave the request alone and let normal cache validation happen.
val offlineCacheInterceptor = Interceptor { chain ->
var request = chain.request()
if (!isNetworkAvailable()) {
request = request.newBuilder()
.cacheControl(CacheControl.FORCE_CACHE)
.build()
}
chain.proceed(request)
}
OkHttpClient is not a lightweight object you should build fresh for every request. A single instance owns the ConnectionPool, which keeps warm keep-alive connections to hosts you've already talked to, roughly five idle connections held for five minutes by default. It also owns the Dispatcher's thread pools that run asynchronous calls, and the Cache if you've configured one. Building a new OkHttpClient per request throws all of that away: every request pays a fresh TCP handshake and, for HTTPS, a fresh TLS handshake, instead of reusing a connection that's already warm. The fix is to construct one OkHttpClient, usually as a singleton or through dependency injection, and share it across the whole app.
object HttpClient {
val instance: OkHttpClient = OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024))
.build()
}
OkHttp gives you four separate timeout knobs, and mixing them up is a common interview stumble. connectTimeout covers the TCP and TLS handshake. readTimeout covers an idle gap while waiting for bytes to arrive. writeTimeout covers an idle gap while sending bytes. All three are per-phase and apply independently to each network operation. callTimeout is different: it's a cap on the entire call, including redirects, retries, and the body, measured start to finish. Its default is zero, which means no overall cap at all. So if you set connect, read, and write timeouts to ten seconds each but leave callTimeout at its default of zero, a call that follows two redirects can legally take well over thirty seconds total, because each hop gets its own fresh ten-second budget and nothing is watching the total.
val client = OkHttpClient.Builder()
.callTimeout(0, TimeUnit.SECONDS) // 0 = no overall cap
.connectTimeout(10, TimeUnit.SECONDS) // per-phase limits
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
Separate from the connection pool, OkHttp's Dispatcher controls how many asynchronous calls, the ones you start with enqueue, are allowed to run at the same time. Two settings govern that: maxRequests caps the total number of in-flight async calls across every host, sixty four by default, and maxRequestsPerHost caps concurrency to any single host, five by default. If you're hammering one API with a burst of parallel calls and they seem to queue up even though the rest of the app is idle, maxRequestsPerHost is almost always the reason. It's easy to confuse this with the ConnectionPool, but the pool governs how many idle keep-alive connections are kept warm for reuse, which is a separate concern from how many requests are allowed to be in flight at once.
val dispatcher = Dispatcher().apply {
maxRequests = 64
maxRequestsPerHost = 10 // raised from default 5 for one busy host
}
val client = OkHttpClient.Builder()
.dispatcher(dispatcher)
.build()
Certificate pinning is a defense against a compromised or coerced certificate authority issuing a valid-looking certificate for your domain that isn't actually yours. You configure it with CertificatePinner, built with add calls that map a hostname to one or more expected SHA-256 hashes of the certificate's public key, and set on OkHttpClient.Builder with certificatePinner. It's a client-level setting, not something you touch per request, and it's enforced during the TLS handshake itself: if the server's certificate chain doesn't produce one of the pinned hashes, the connection fails before any request or response ever happens. Because it's this strict, always pin a backup key alongside your primary one and have a rotation plan ready. If your only pinned certificate expires or gets rotated on the server without a matching client update, pinning bricks the app's networking entirely.
val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.build()
OkHttp's gzip handling is asymmetric, and that asymmetry trips people up. On the response side it's fully transparent: as long as you don't set Accept-Encoding yourself, OkHttp requests gzip, decompresses the response automatically, and strips the Content-Encoding and Content-Length headers so your code never even knows compression happened. On the request side, none of that exists. OkHttp never compresses a request body for you, no matter how large it is. If you want to send a gzip-compressed body, you write an interceptor yourself that sets Content-Encoding: gzip on the outgoing request and wraps the RequestBody so its bytes are actually gzipped as they're written, typically using Okio's GzipSink around the sink you're given.
val gzipInterceptor = Interceptor { chain ->
val original = chain.request()
val gzippedBody = object : RequestBody() {
override fun contentType() = original.body?.contentType()
override fun writeTo(sink: BufferedSink) {
val gzipSink = GzipSink(sink).buffer()
original.body?.writeTo(gzipSink)
gzipSink.close()
}
}
chain.proceed(
original.newBuilder()
.header("Content-Encoding", "gzip")
.method(original.method, gzippedBody)
.build()
)
}
Put the application and network distinction under real interview pressure for a second. Application interceptors can short-circuit a call entirely by returning a response without ever calling chain.proceed(), which is how you'd fake a response in a test. They can also retry by calling chain.proceed() more than once inside the same interceptor. And because they run once per logical call, they see the response as a single resolved result after any redirects have already been followed underneath them, not hop by hop. What they cannot do is reach chain.connection() for TLS or IP details about the underlying socket. That capability belongs to network interceptors, which run closer to the wire and have an actual Connection to inspect. Mixing this up, thinking application interceptors can see connection details, is one of the most common wrong answers in an OkHttp interview question.
.addInterceptor { chain ->
println(chain.connection()) // always null here
chain.proceed(chain.request())
}
.addNetworkInterceptor { chain ->
val conn = chain.connection() // non-null; TLS version, cipher, etc.
chain.proceed(chain.request())
}