Building a Central OkHttp Interceptor for Session Eviction, Force Upgrades, and Maintenance Mode

One place that watches every API response, so a 401, a 426, or a 503 routes the whole app correctly — instead of being re-handled (and forgotten) in a hundred screens.

Building a Central OkHttp Interceptor for Session Eviction, Force Upgrades, and Maintenance Mode

One place that watches every API response, so a 401, a 426, or a 503 routes the whole app correctly — instead of being re-handled (and forgotten) in a hundred screens.


About the code in this article

Everything here comes from a real, open-source app — not a contrived snippet. WC26 Threads is an Android social feed I built as a portfolio project: a Threads-style app for talking about the 2026 World Cup, written in Kotlin with Jetpack Compose, Retrofit/OkHttp, Hilt, and a cursor-paginated backend.

The full source is on GitHub, so you can open every file referenced below and see it in context:

Repo: github.com/AdelAchour/wc26-android

The files we’ll walk through:

Snippets below are lightly trimmed for readability; the repo has the unabridged versions. With that, let’s dig in.


The problem: app-blocking events live everywhere and nowhere

Every networked app eventually has to handle a few responses that aren’t really about the screen that made the request. They’re about the whole app:

  • 401 Unauthorized — the user’s token expired or was revoked on another device. The session is dead; they need to be logged out and sent back to the welcome screen.
  • 426 Upgrade Required — this build is too old for the API. The user must update before anything will work.
  • 503 Service Unavailable — the backend is in maintenance mode. Nobody can do anything until it’s back.

These can come back from any endpoint, at any time. The feed, the profile screen, posting a comment — any of them might be the request that discovers the server went into maintenance, or that the token just got evicted.

The naive approach handles them where they surface: a try/catch in the feed repository that checks for 401, another check buried in the profile ViewModel, a maintenance banner wired into one screen but not the others. This scatters the same policy across dozens of call sites, and the bugs are predictable:

  • One screen logs out on 401, another shows a confusing “request failed” toast.
  • Force-update is enforced on the feed but not on the screen the user happens to be on.
  • A new feature ships, the developer forgets the maintenance check, and that screen silently breaks during the next outage.

The fix is to handle these once, at the only layer every request already passes through: the HTTP client. That’s what an OkHttp Interceptor is for.


The mental model: one choke point, one status bus

The architecture has three moving parts, and the whole design is about keeping them decoupled:

  Any API call


  ┌──────────────────┐   reads 401 / 426 / 503
  │  AuthInterceptor │ ─────────────────────────┐
  │  (OkHttp layer)  │                           │
  └────────┬─────────┘                           ▼
           │ passes response                ┌──────────────────┐
           │ through unchanged              │ AppStatusManager │  StateFlow<AppStatus>
           ▼                                └────────┬─────────┘
   normal app code                                   │ observed by
   (repos, ViewModels)                               ▼
                                            ┌──────────────────┐
                                            │   WC26NavHost    │  routes to
                                            │  (navigation)    │  ForceUpdate / Maintenance
                                            └──────────────────┘
  1. The interceptor sits inside OkHttp and inspects every response code. It never decides what screen to show — that’s not its job and it has no access to navigation. It just detects the condition and reports it.
  2. AppStatusManager is a tiny singleton holding a StateFlow<AppStatus>. It’s a one-way bus: the interceptor pushes a status in, the UI observes it out. Neither side knows about the other.
  3. The nav host observes that flow and decides routing. UI policy lives in the UI layer, where it belongs.

This separation is the entire point. The interceptor runs on background OkHttp threads with no Compose, no NavController, no lifecycle. By having it emit into a StateFlow instead of touching navigation directly, we bridge cleanly from “a network thread noticed something” to “the UI reacts on the main thread.”


Layer 1: The status bus

Start with the simplest piece, because everything else points at it:

sealed interface AppStatus {
    object Normal : AppStatus
    data class ForceUpdate(val updateUrl: String, val minVersion: Int) : AppStatus
    object Maintenance : AppStatus
}

@Singleton
class AppStatusManager @Inject constructor() {
    private val _appStatus = MutableStateFlow<AppStatus>(AppStatus.Normal)
    val appStatus: StateFlow<AppStatus> = _appStatus.asStateFlow()

    fun updateStatus(status: AppStatus) {
        _appStatus.value = status
    }
}

That’s the whole thing. A sealed interface models the three possible app-wide states, and a singleton holds the current one in a StateFlow. ForceUpdate carries the data the UI will need (where to send the user, what version they need) so the interceptor can pass along what it parsed from the server. Because it’s a @Singleton, there’s exactly one of these in the app, and anyone — interceptor or UI — can inject it.

Keeping this dead simple is deliberate. It’s a bus, not a brain. It doesn’t decide anything; it just remembers the latest status and notifies observers.


Layer 2: The interceptor

Now the heart of it. An OkHttp Interceptor gets a crack at every request on the way out and every response on the way back. Ours does two jobs: decorate outgoing requests with auth and version headers, and inspect incoming responses for the three blocking codes.

Decorating the request

override fun intercept(chain: Interceptor.Chain): Response {
    val token = runBlocking { tokenStore.getToken() }

    val request = chain.request().newBuilder()
        .header("User-Agent", "WC26-Android/${BuildConfig.VERSION_NAME}")
        .addHeader("X-App-Version", BuildConfig.VERSION_CODE.toString())
        .apply {
            if (token != null) {
                addHeader("Authorization", "Bearer $token")
            }
        }
        .build()

    val response = chain.proceed(request)
    // ... inspect response below
}

Two things ride on every outgoing request:

  • Authorization: Bearer <token> — pulled from TokenStore. Doing this centrally means no individual API call ever has to remember to attach the token. (runBlocking is used because the token lives in a coroutine-based DataStore but intercept is a synchronous OkHttp callback. It’s a pragmatic bridge; the read is a fast local lookup.)
  • X-App-Version — the build’s version code. This is the other half of the force-update handshake: the server compares this header against its minimum supported version and replies 426 when the app is too old. The client doesn’t have to ask whether it’s outdated; it announces its version on every call and the server tells it when to stop.

chain.proceed(request) actually fires the network call and gives us the Response. Everything after that is inspection.

Detecting 401: session eviction

if (response.code == 401 && token != null) {
    runBlocking { tokenStore.clear() }
}

The simplest case. If we sent a token and the server still said 401, that token is no longer valid — expired, revoked, or evicted because the user logged in elsewhere. We clear it from TokenStore. We don’t navigate here; clearing the token is enough, because (as we’ll see) the nav host is also observing the login state and reacts to the token disappearing. The interceptor’s only responsibility is to invalidate the dead credential.

Detecting 426 and 503: reading the body without consuming it

Here’s the genuinely tricky part, and the reason a naive implementation breaks.

For 401 the status code alone told us everything. But 426 and 503 carry a JSON payload we need to read — the update URL and minimum version for a force-update, the maintenance flag for an outage. The problem: an HTTP response body is a one-shot stream. If the interceptor reads it to inspect it, the real caller downstream (Retrofit, your repository) gets an empty, already-consumed stream and the request blows up.

OkHttp ships with Okio precisely to solve this. We can buffer the body into memory and read a copy, leaving the original stream intact for whoever consumes it next:

if (response.code == 426) {
    val source = response.body?.source()
    source?.request(Long.MAX_VALUE)          // 1. pull the entire body into Okio's buffer
    val buffer = source?.buffer
    val bodyString = buffer?.clone()          // 2. clone so we don't drain the original
        ?.readString(Charset.forName("UTF-8"))

    if (bodyString != null) {
        val updateError = runCatching {
            json.decodeFromString<ForceUpdateErrorDto>(bodyString)
        }.getOrNull()

        val url = updateError?.androidUpdateUrl
            ?: "https://play.google.com/store/apps/details?id=com.adel.wc26"
        val minVersion = updateError?.minAndroidVersion ?: 1
        appStatusManager.updateStatus(
            AppStatus.ForceUpdate(updateUrl = url, minVersion = minVersion)
        )
    }
}

The two lines that matter:

  • source.request(Long.MAX_VALUE) tells Okio: “read the entire body into the buffer, however big it is.” (request(n) means “ensure at least n bytes are buffered”; Long.MAX_VALUE effectively means “all of it.”) After this call the whole body is sitting in source.buffer — but, importantly, still available to be read normally by the downstream consumer. We haven’t consumed it; we’ve just made sure it’s fully loaded.
  • buffer.clone().readString(...) is the key trick. readString is destructive — it drains the bytes it reads. If we called it on the buffer directly, we’d empty it and the real caller would get nothing. So we clone() the buffer (a cheap, shallow copy in Okio) and read the clone. The original buffer is untouched, the response flows downstream perfectly intact, and we still got our string.

This is the canonical Okio pattern for “peek at a body without eating it,” and it’s exactly what lets a global interceptor parse payloads without corrupting every request in the app.

Once we have the string, we parse it into a ForceUpdateErrorDto and push a ForceUpdate status onto the bus. Note the defensive defaults: parsing is wrapped in runCatching, and if the server’s payload is missing or malformed we fall back to the Play Store URL and a sane minimum version — a garbled 426 should still get the user to the update screen, not crash.

Maintenance mode (503) uses the same buffer-and-clone dance, with a lighter-weight check:

if (response.code == 503) {
    val source = response.body?.source()
    source?.request(Long.MAX_VALUE)
    val bodyString = source?.buffer?.clone()?.readString(Charset.forName("UTF-8"))
    if (bodyString != null && bodyString.contains("\"maintenance_mode\":true")) {
        appStatusManager.updateStatus(AppStatus.Maintenance)
    }
}

A 503 can come from infrastructure that isn’t your app (a load balancer, a CDN), so we don’t blindly treat every 503 as maintenance — we confirm our own backend’s "maintenance_mode":true marker is present before flipping the switch. (A string contains check is a pragmatic shortcut here; you could just as well decode MaintenanceErrorDto like the 426 branch does.)

The matching DTOs live in SystemApi.kt:

@Serializable
data class ForceUpdateErrorDto(
    val error: String,
    @SerialName("android_update_url") val androidUpdateUrl: String,
    @SerialName("min_android_version") val minAndroidVersion: Int
)

In every branch, notice what the interceptor does not do: it never navigates, never shows a dialog, never touches Compose. It detects, it parses, it emits onto the bus. Then it returns response unchanged so the original caller is none the wiser.


Layer 3: Wiring it into OkHttp

An interceptor does nothing until it’s registered on the client. That happens once, in the Hilt network module:

@Provides
@Singleton
fun provideOkHttpClient(
    authInterceptor: AuthInterceptor,
): OkHttpClient {
    return OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .addInterceptor(logging)
        .build()
}

Because the single OkHttpClient is shared by every Retrofit service in the app, registering the interceptor here means every API call — present and future — automatically flows through it. Add a new feature with a new endpoint tomorrow, and it’s covered with zero extra work. That’s the payoff of centralizing: the policy applies by construction, not by remembering.

Hilt injects AuthInterceptor (which itself gets TokenStore, AppStatusManager, and the JSON serializer injected), so the whole graph is wired without any manual plumbing.


Layer 4: Bridging back to navigation

The last mile turns a status emission into an actual screen change. The nav host observes the bus and reacts:

val appStatus by appStatusManager.appStatus.collectAsStateWithLifecycle()

LaunchedEffect(appStatus) {
    when (appStatus) {
        is AppStatus.ForceUpdate -> {
            navController.navigate(Destinations.ForceUpdate(
                updateUrl = (appStatus as AppStatus.ForceUpdate).updateUrl,
                minVersion = (appStatus as AppStatus.ForceUpdate).minVersion,
            )) { /* clear back stack so the user can't escape */ }
        }
        is AppStatus.Maintenance -> {
            navController.navigate(Destinations.Maintenance) { /* clear back stack */ }
        }
        is AppStatus.Normal -> {
            // if we're currently on a blocking screen, leave it
        }
    }
}

This closes the loop. A 503 from some background request on the feed flips AppStatusManager to Maintenance; because the nav host is collecting that StateFlow with collectAsStateWithLifecycle, the LaunchedEffect fires and routes to the maintenance screen — no matter which screen the user was on. The blocking event is enforced globally, exactly once, from data layer to pixels.

The 401 case is handled by a parallel observer in the same nav host, watching the login state rather than appStatus:

val isLoggedIn by tokenStore.isLoggedInFlow.collectAsState(initial = false)
// when this flips true -> false mid-session, navigate to Welcome and clear the back stack

So the chain for session eviction is: interceptor sees 401 → clears the token → TokenStore.isLoggedInFlow emits false → nav host force-navigates to Welcome. The interceptor and the navigation never reference each other; they communicate entirely through state.


Why this design holds up

What you get:

  • Single source of truth for app-blocking events. 401/426/503 are handled in exactly one place. There’s no “did we remember the maintenance check on this screen?” because individual screens never do the check at all.
  • New endpoints are covered for free. Anything that goes through the shared OkHttpClient is automatically protected. Coverage is structural, not a checklist.
  • Clean thread/layer boundaries. The interceptor runs on OkHttp’s background threads and only ever emits into a StateFlow. The UI collects on the main thread with lifecycle awareness. Neither layer reaches into the other.
  • Bodies are parsed safely. The Okio buffer-and-clone pattern lets a global interceptor read status payloads without breaking the actual request — the single biggest gotcha when inspecting bodies in an interceptor.

The trade-offs, honestly:

  • runBlocking in the interceptor. Reading the token (and clearing it on 401) blocks the OkHttp thread on a coroutine. The reads are fast local DataStore lookups, so in practice it’s fine, but it’s a bridge between two concurrency models, not a purist solution.
  • The whole body is buffered into memory. request(Long.MAX_VALUE) is perfect for small JSON error payloads. You would not want this on a large streaming/download response — which is why the buffering only runs inside the 426/503 branches, never on the happy path.
  • The 503 string check is a heuristic. contains("\"maintenance_mode\":true") is simple but brittle to formatting changes; decoding MaintenanceErrorDto would be more robust if the payload shape ever varies.

Takeaways

If you remember one thing: handle app-wide HTTP conditions at the one layer every request shares, and bridge them to the UI through state — never let the interceptor touch navigation directly.

The recipe:

  1. Put a single Interceptor on your shared OkHttpClient so every call is inspected by construction.
  2. Decorate requests centrally (auth token, version header) and inspect responses centrally (401/426/503).
  3. To read an error body without breaking the request, use Okio: source.request(Long.MAX_VALUE) to buffer it, then read a clone() of the buffer so the original stream survives.
  4. Emit a small sealed AppStatus onto a singleton StateFlow bus instead of navigating from the network layer.
  5. Let the UI observe that bus and own all routing decisions.

The result is an app where an expired session, a forced upgrade, or a maintenance window is handled the same correct way no matter where it’s discovered — with the network layer and the UI layer fully decoupled.


All code in this article is from WC26 Threads, my open-source World Cup social feed: github.com/AdelAchour/wc26-android. Files referenced: AuthInterceptor.kt, AppStatusManager.kt, SystemApi.kt, NetworkModule.kt, WC26NavHost.kt.