Retrofit, OkHttp, Interceptors, Token Refresh & Pagination
Production-grade networking: auth flows, retry logic, error mapping, and cursor/offset pagination patterns
Open interactive version (quiz + challenge)Real-world analogy
What is it?
OkHttp retry logic: retry failed requests with exponential backoff on 503/429, add auth interceptors, refresh tokens on 401, and paginate — with Kotlin code.
Real-world relevance
At Payback (fintech app), every API call requires a bearer token that expires every 15 minutes. The AuthInterceptor stamps every request with the current token. When a 401 arrives, the Authenticator synchronously calls the refresh endpoint, updates the token in EncryptedSharedPreferences, and retries the original request — the user never sees a logout screen. For the transaction history screen, cursor pagination handles the real-time feed correctly — new transactions added while browsing don't cause skipped items like offset pagination would.
Key points
- Retrofit fundamentals — Retrofit turns HTTP API definitions into Kotlin interfaces. @GET, @POST, @PUT, @DELETE, @PATCH annotate functions. @Path for URL segments, @Query for query params, @Body for request body, @Header for single headers, @Headers for static headers. Return types: suspend fun T, suspend fun Response, Call.
- OkHttp as the HTTP engine — Retrofit delegates all HTTP work to OkHttp. OkHttp manages connection pooling, response caching, TLS, and request/response interception. Configure OkHttp via OkHttpClient.Builder() — set timeouts, add interceptors, configure TLS/certificate pinning, add cache. Pass to Retrofit via .client(okHttpClient).
- Application interceptors — Added via addInterceptor() — execute once per logical request, even if the request is cached. Use for: adding auth headers to every request, logging request/response bodies, transforming responses globally. Run in the order added. chain.proceed(request) passes the request down the chain.
- Network interceptors — Added via addNetworkInterceptor() — execute for each network transmission (not for cached responses). Use for: monitoring actual network traffic, modifying cache headers, tracking raw bytes transferred. Useful for analytics on actual network usage vs. cache usage.
- Auth interceptor pattern — Intercepts every request, reads the token from a TokenProvider/AuthRepository, and adds 'Authorization: Bearer ' header. Should NOT perform token refresh (that's the Authenticator's job). Keep the interceptor simple: add header, proceed, return response.
- OkHttp Authenticator for token refresh — Authenticator.authenticate() is called automatically when a 401 response is received. Inside: synchronously refresh the token (critical — must be synchronized to prevent multiple concurrent refreshes), update stored token, return the original request with the new token. Return null to give up and propagate the 401. Use a Mutex or synchronized block.
- Retry interceptor — Intercepts failed responses and retries with exponential backoff. Check for retryable conditions: network errors, 503, 429. Implement a retry count limit. Use Thread.sleep() (interceptors run on non-main threads) or coroutine delay if using CoroutineInterceptor. Important: do NOT retry non-idempotent requests (POST) without understanding business consequences.
- Error mapping to domain exceptions — Map HTTP errors to typed domain exceptions before they reach the ViewModel. In a repository or network result mapper: 401 → UnauthorizedException, 404 → NotFoundException, 422 → ValidationException(errors), 503 → ServerUnavailableException, network exception → NoConnectivityException. ViewModel receives typed exceptions, not raw HttpException.
- Offset pagination — Classic approach: pass page=1&pageSize=20 as query params. Server returns total count + items. Client tracks current page, increments on load-more. Simple to implement, easy to jump to arbitrary pages. Problem: if items are inserted/deleted between pages, you get duplicates or skipped items.
- Cursor pagination — Server returns a next_cursor with each response. Client passes the cursor in the next request. Cursor encodes the position (e.g., last item ID or timestamp). Handles insertions/deletions between pages correctly. No way to jump to arbitrary page. Preferred for real-time feeds (social, notifications). Twitter, Slack, GitHub all use cursor pagination.
- Paging 3 library integration — PagingSource defines how to load pages — loadBefore/loadAfter with LoadResult.Page or LoadResult.Error. RemoteMediator bridges network + Room for offline-first paging. Pager + PagingConfig creates a Flow>. UI uses LazyColumn + collectAsLazyPagingItems() in Compose or PagingDataAdapter in RecyclerView.
- Network result wrapper pattern — Wrap all API calls in a sealed class: sealed class NetworkResult { data class Success(val data: T) : NetworkResult(); data class Error(val exception: DomainException) : NetworkResult(); class Loading : NetworkResult() }. Repository returns NetworkResult, ViewModel maps to UiState. Eliminates try/catch scattered through ViewModels.
- Quick answer: what is an OkHttp interceptor? — An OkHttp interceptor is a class implementing the Interceptor interface that plugs into OkHttpClient's request/response chain via addInterceptor() (once per logical request) or addNetworkInterceptor() (once per actual network call). Inside intercept(chain), it can read or rewrite the request, call chain.proceed(request), then read or rewrite the response before returning it — the standard place for auth headers, logging, and retries in an Android app.
- Minimal interceptor — smallest working example — Skip the auth/retry complexity for a moment — here is the smallest interceptor that compiles and does something useful (adds a header, then hands the request back to the chain). Every interceptor you write, no matter how complex, follows this same 4-line shape.
- HttpLoggingInterceptor — see every request/response in Logcat — Before writing a custom interceptor, add OkHttp's own logging interceptor first — it prints method, URL, headers, and body for every call, and is the fastest way to debug 'why is my request failing'. Add it LAST via addInterceptor() so it logs the fully-built request (after auth headers are attached). Never ship BODY level in a release build — it can leak tokens and PII into device logs.
- Testing an interceptor in isolation with MockWebServer — Interviewers often follow up 'how would you unit test that AuthInterceptor?' with a straight yes/no on whether you've actually done it. MockWebServer (from okhttp3.mockwebserver) starts a real local HTTP server your OkHttpClient talks to — no mocking framework needed. Enqueue a canned response, fire the request through your real interceptor chain, then assert on the recorded request that reached the server (headers, body, path). This catches interceptor bugs that Mockito-style unit tests miss, because the request actually travels through chain.proceed().
- RetryInterceptor — full exponential-backoff implementation — The 'Retry interceptor' point above describes the shape; this is the actual working class most tutorials skip. Key rules an interviewer checks for: retry only idempotent methods (GET, never POST), cap the attempt count, sleep on the interceptor's own thread (never the main thread — OkHttp interceptors already run off it), and read Retry-After when the server sends one instead of guessing the delay.
Code example
// 1. Retrofit interface definition
interface PaybackApiService {
@GET("transactions")
suspend fun getTransactions(
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int = 20
): TransactionPageResponse
@POST("payments")
suspend fun createPayment(@Body request: PaymentRequest): Response<PaymentResponse>
@GET("users/{userId}/profile")
suspend fun getProfile(@Path("userId") userId: String): ProfileResponse
}
// 2. Auth Interceptor — adds bearer token to every request
class AuthInterceptor @Inject constructor(
private val tokenProvider: TokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.getAccessToken()
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.header("X-App-Version", BuildConfig.VERSION_NAME)
.build()
return chain.proceed(request)
}
}
// 3. Authenticator — handles 401 token refresh
class TokenAuthenticator @Inject constructor(
private val tokenProvider: TokenProvider,
private val authApi: AuthApiService
) : Authenticator {
// Mutex prevents concurrent refresh races
private val refreshMutex = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
// If we've already retried, give up
if (response.request.header("X-Retry-Auth") != null) return null
return runBlocking {
refreshMutex.withLock {
// Check if another coroutine already refreshed while we waited
val currentToken = tokenProvider.getAccessToken()
val requestToken = response.request
.header("Authorization")?.removePrefix("Bearer ")
if (currentToken != requestToken) {
// Token already refreshed by another request — just retry
return@withLock response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
// Actually refresh
try {
val refreshResponse = authApi.refreshToken(
tokenProvider.getRefreshToken()
)
tokenProvider.saveTokens(
refreshResponse.accessToken,
refreshResponse.refreshToken
)
response.request.newBuilder()
.header("Authorization", "Bearer ${refreshResponse.accessToken}")
.header("X-Retry-Auth", "true")
.build()
} catch (e: Exception) {
tokenProvider.clearTokens() // Force re-login
null
}
}
}
}
}
// 4. Cursor pagination — PagingSource
class TransactionPagingSource(
private val api: PaybackApiService
) : PagingSource<String, Transaction>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Transaction> {
return try {
val response = api.getTransactions(
cursor = params.key, // null on first load
limit = params.loadSize
)
LoadResult.Page(
data = response.transactions,
prevKey = null, // cursor pagination is forward-only
nextKey = response.nextCursor // null = no more pages
)
} catch (e: HttpException) {
LoadResult.Error(mapHttpException(e))
} catch (e: IOException) {
LoadResult.Error(NoConnectivityException())
}
}
override fun getRefreshKey(state: PagingState<String, Transaction>): String? = null
}
// 5. Error mapping — HTTP to domain exceptions
fun mapHttpException(e: HttpException): DomainException = when (e.code()) {
401 -> UnauthorizedException("Session expired")
403 -> ForbiddenException("Insufficient permissions")
404 -> NotFoundException("Resource not found")
422 -> {
val errorBody = e.response()?.errorBody()?.string()
ValidationException(parseValidationErrors(errorBody))
}
503 -> ServerUnavailableException("Service temporarily unavailable")
else -> UnknownApiException(e.code(), e.message())
}
// 6. OkHttp client assembly
@Provides @Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
authenticator: TokenAuthenticator
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG)
HttpLoggingInterceptor.Level.BODY
else
HttpLoggingInterceptor.Level.NONE
})
.addInterceptor(authInterceptor)
.authenticator(authenticator)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()Line-by-line walkthrough
- 1. Retrofit interface uses @Query('cursor') with a nullable default — on first page load, cursor is null, server returns from the beginning.
- 2. AuthInterceptor.intercept() calls chain.request().newBuilder() to create a mutated copy of the request — OkHttp Requests are immutable so you must build a new one.
- 3. TokenAuthenticator uses runBlocking inside authenticate() because OkHttp Authenticator is a blocking Java interface — coroutine suspend is not directly supported here.
- 4. refreshMutex.withLock ensures only one coroutine performs the refresh — others wait, then find the token already updated and skip the refresh call.
- 5. Checking requestToken vs currentToken inside the lock detects 'token already refreshed by another request' — retry with new token without making another network call.
- 6. TransactionPagingSource.load() receives params.key as the cursor — null on initial load, nextCursor on subsequent loads.
- 7. LoadResult.Page sets prevKey = null because cursor pagination is forward-only — Paging 3 disables prepend loading.
- 8. mapHttpException() is a pure function that transforms HttpException codes to sealed class instances — no Android dependencies, unit testable.
- 9. HttpLoggingInterceptor level is set to BODY only in DEBUG builds — never log request/response bodies in production (security and performance).
- 10. addInterceptor(authInterceptor) runs before authenticator — the interceptor adds the current token, authenticator handles 401 renewal.
Spot the bug
// Find 4 bugs in this networking setup
class BrokenAuthenticator(
private val tokenProvider: TokenProvider,
private val authApi: AuthApiService
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val newTokens = runBlocking {
authApi.refreshToken(tokenProvider.getRefreshToken()) // Bug 1
}
tokenProvider.saveTokens(newTokens.accessToken, newTokens.refreshToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build() // Bug 2
}
}
class BrokenAuthInterceptor(
private val tokenProvider: TokenProvider,
private val authApi: AuthApiService // Bug 3
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.getAccessToken()
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
val response = chain.proceed(request)
if (response.code == 401) {
runBlocking { authApi.refreshToken(tokenProvider.getRefreshToken()) } // Bug 4
}
return response
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- OkHttp Interceptors — Official Guide (OkHttp)
- Retrofit — Square Documentation (Square)
- Android Paging 3 Library (Android Developers)
- Token Refresh with OkHttp Authenticator — Race condition prevention (ProAndroidDev)
- Cursor vs Offset Pagination — When to use each (Medium)
- MockWebServer — Testing HTTP clients (OkHttp / Square)