Lesson 27 of 83 advanced

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

Retrofit is the waiter who takes your order (API call) and brings back your food (response). OkHttp is the kitchen — it does the actual work. Interceptors are the kitchen stations the order passes through: the logging station writes everything down, the auth station stamps your order with your ID badge, and the retry station sends the order back if the kitchen is temporarily closed.

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

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. 1. Retrofit interface uses @Query('cursor') with a nullable default — on first page load, cursor is null, server returns from the beginning.
  2. 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. 3. TokenAuthenticator uses runBlocking inside authenticate() because OkHttp Authenticator is a blocking Java interface — coroutine suspend is not directly supported here.
  4. 4. refreshMutex.withLock ensures only one coroutine performs the refresh — others wait, then find the token already updated and skip the refresh call.
  5. 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. 6. TransactionPagingSource.load() receives params.key as the cursor — null on initial load, nextCursor on subsequent loads.
  7. 7. LoadResult.Page sets prevKey = null because cursor pagination is forward-only — Paging 3 disables prepend loading.
  8. 8. mapHttpException() is a pure function that transforms HttpException codes to sealed class instances — no Android dependencies, unit testable.
  9. 9. HttpLoggingInterceptor level is set to BODY only in DEBUG builds — never log request/response bodies in production (security and performance).
  10. 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?
Look at concurrency safety, infinite loop prevention, separation of responsibilities, and which component should handle which concern.
Show answer
Bug 1: No mutex/synchronization around the token refresh — if two requests receive 401 simultaneously, both will call refreshToken() concurrently. The first refresh invalidates the refresh token; the second call will fail (most OAuth servers allow single-use refresh tokens), causing a logout. Fix: wrap with a Mutex and check if token was already refreshed before calling the API. Bug 2: Missing the 'X-Retry-Auth' guard — there's no check to detect if we've already retried. If the new token is also rejected (e.g., account suspended), authenticate() is called again infinitely. Fix: check if response.request.header('X-Retry-Auth') != null and return null (give up) before refreshing. Bug 3: AuthInterceptor injects AuthApiService — the interceptor that adds auth headers to ALL requests is also holding a reference to the auth API. This creates a circular dependency risk and violates single responsibility. The interceptor should only add headers; token refresh is the Authenticator's job. Bug 4: BrokenAuthInterceptor is doing token refresh itself on 401 — this is the Authenticator's responsibility. Having both the interceptor and authenticator handle 401 means token refresh can be triggered twice for the same request, causing race conditions. Remove the 401 check from the interceptor entirely — let OkHttp route 401s to the Authenticator.

Explain like I'm 5

Imagine ordering food through an app. Retrofit is the order screen — you pick what you want and press send. OkHttp is the delivery driver who actually goes to the restaurant. Interceptors are checkpoints on the way: one writes down every order in a notebook (logging), another shows your membership card at the restaurant door (auth). If your card expired (401), a special agent (Authenticator) runs to get you a new card, then the driver retries the delivery — you don't even notice.

Fun fact

OkHttp's connection pool reuses TCP connections by default (5 connections, 5 minutes idle). This means your second API call in an app session is typically 30-50% faster than the first because TLS handshake and TCP connection setup are skipped. On mobile networks with high latency, this can save 200-500ms per request.

Hands-on challenge

Implement a RetryInterceptor that retries requests up to 3 times with exponential backoff (1s, 2s, 4s) for 503 responses and IOException. Ensure it does NOT retry POST requests. Then write the error mapping function that converts HttpException and IOException to a sealed class NetworkError with at least 5 subtypes. Wire both into the OkHttpClient builder.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery