What do OkHttp interceptors actually let you do, and what does a real interceptor chain look like?
Interceptors are the one seam where you can see and rewrite every request and response OkHttp handles — auth headers, logging, retries, and caching all live here.
LEARN BY DOINGPut your intuition to work3 steps
What do OkHttp interceptors actually let you do, and what does a real interceptor chain look like?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The interceptor is just a link in a chain of responsibility
Every OkHttp request travels through a chain of Interceptor objects before it reaches the network, and every response travels back through the same chain in reverse. Each interceptor implements one method: intercept(chain: Interceptor.Chain): Response. Inside it, you can inspect or rewrite the outgoing Request, call chain.proceed(request) to hand off to the next link, and then inspect or rewrite the Response that comes back before returning it yourself. That single proceed call is the whole mechanism — everything OkHttp lets you do (auth headers, logging, retries, gzip, caching) is built on top of it.
class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor {override fun intercept(chain: Interceptor.Chain): Response {val original = chain.request()val authenticated = original.newBuilder().header("Authorization", "Bearer ${tokenStore.accessToken}").build()return chain.proceed(authenticated)}}
#Application interceptors vs. network interceptors
OkHttp gives you two attachment points, and the difference is a real interview question, not trivia. addInterceptor() registers an application interceptor: it runs exactly once per user-visible call, never sees redirects or retries happening underneath it, and won't see a response that OkHttp served straight from its disk cache without touching the network. addNetworkInterceptor() registers a network interceptor: it sits right next to the socket, runs once per actual network request (so it does see every redirect and retry as a separate pass), sees the compressed bytes on the wire, and is skipped entirely when a cached response satisfies the call without a network round trip.
val client = OkHttpClient.Builder()// Application interceptors: see the logical call, not the wire..addInterceptor(AuthInterceptor(tokenStore)).addInterceptor(HttpLoggingInterceptor().apply {level = HttpLoggingInterceptor.Level.BASIC})// Network interceptor: sees the actual request/response on the wire,// including ones the cache above never touches..addNetworkInterceptor(StethoInterceptor()).cache(Cache(File(context.cacheDir, "http_cache"), 20L * 1024 * 1024)).build()
If you want to add a header to every real network attempt (say, for a CDN that inspects raw wire traffic), use a network interceptor. If you want to add an Authorization header to every logical call the app makes regardless of caching, an application interceptor is both simpler and correct.
#A real chain: auth, refresh, and logging together
The chain that shows up in almost every production app combines an interceptor that attaches the current token, an Authenticator (a related but distinct OkHttp hook, called specifically when a response comes back 401) that refreshes the token and retries, and a logging interceptor placed last so it logs what actually went over the wire, tokens included in debug builds only.
class TokenAuthenticator(private val tokenStore: TokenStore) : Authenticator {private val lock = Any()override fun authenticate(route: Route?, response: Response): Request? {// Give up after one retry so a permanently invalid token doesn't loop forever.if (responseCount(response) >= 2) return nullsynchronized(lock) {// Another thread may have already refreshed while we waited for the lock —// re-check before firing a second network call for the same problem.val current = tokenStore.accessTokenval failedToken = response.request.header("Authorization")?.removePrefix("Bearer ")if (current != failedToken) {return response.request.newBuilder().header("Authorization", "Bearer $current").build()}val refreshed = tokenStore.refreshBlocking() ?: return nullreturn response.request.newBuilder().header("Authorization", "Bearer $refreshed").build()}}private fun responseCount(response: Response): Int {var result = 1var prior = response.priorResponsewhile (prior != null) { result++; prior = prior.priorResponse }return result}}val client = OkHttpClient.Builder().addInterceptor(AuthInterceptor(tokenStore)).authenticator(TokenAuthenticator(tokenStore)).addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }).build()
The synchronized block matters more than it looks: without it, ten requests that all get a 401 at the same moment each fire their own refresh call, hammering your auth endpoint and racing each other for which token wins. Locking, plus re-checking whether another thread already refreshed while you waited, turns ten refresh calls into one.
#Order is a stack, not a list
Interceptors added via addInterceptor run in the order you add them on the way out, and in reverse order on the way back — the first one added is the outermost wrapper, the last one added is closest to the actual call. If your logging interceptor needs to log the final headers OkHttp sends (including ones an earlier interceptor added), it needs to be added after that interceptor, not before.
🧠 Check your understanding
0/1 · 0/1 answered1. You add an interceptor with addNetworkInterceptor() to log every header actually sent on the wire. A request is fully served from OkHttp's disk cache with no network access at all. Does your network interceptor run?