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?
Retrofit is the declarative HTTP client for Android, backed by OkHttp as the HTTP engine. Interceptors are middleware that inspect and modify every request and response — authentication headers, logging, and retry logic all live here. The OkHttp Authenticator handles automatic token refresh on 401 responses. Pagination patterns (offset vs cursor) control how large data sets are loaded incrementally. Mapping HTTP errors to typed domain exceptions keeps ViewModels clean and testable.
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.
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)