简体中文 | English
Clone. Run. Watch the network layer behave.
Turn airplane mode on, expire a token, fire concurrent
401s — and see what a production-ready OkHttp stack should do.
Most Retrofit samples stop at “hello, JSON.” This project focuses on the two bugs that still break real apps:
| Pain | What this cookbook shows |
|---|---|
| Weak / no network | Wi-Fi revalidates, cellular caches for 60s, offline serves stale data for 4 weeks |
| Expired access token | One safe refresh, one retry, no recursion, no NPE under concurrent 401 |
The Compose demo talks to an in-process MockWebServer. No account. No cloud API. No secrets. No internet required.
- Run the app → fetch on CELLULAR → response source:
NETWORK - Switch to OFFLINE → fetch again → source:
CACHE(timeline stays quiet) - Tap Expire access token → fetch online → watch
401 → refresh → retryin the server timeline
You see response source, cache policy, token state, and every local server hit — networking stops being invisible.
Requirements: Android Studio · JDK 17 · Android SDK 36
git clone https://github.com/cheng2016/Retrofit2RxJava-Android-Simples.git
cd Retrofit2RxJava-Android-Simples
./gradlew assembleDebugVerify locally:
./gradlew lintDebug testDebugUnitTest assembleDebugflowchart LR
ComposeUI[ComposeScreen] --> ViewModel[NetworkDemoViewModel]
ViewModel --> Repository[ProfileRepository]
Repository --> Retrofit[RetrofitApi]
Retrofit --> OkHttp[OkHttpClient]
OkHttp --> Cache[NetworkAwareCacheInterceptor]
OkHttp --> Auth[TokenAuthenticator]
Auth --> Store[DataStoreTokenStore]
OkHttp --> Mock[LocalMockWebServer]
Stack: Kotlin · Jetpack Compose · MVVM · Coroutines / StateFlow · Hilt · Retrofit · OkHttp · DataStore · MockWebServer
Full sources:
NetworkAwareCacheInterceptor.kt ·
TokenAuthenticator.kt ·
NetworkModule.kt ·
DemoRepository.kt
Rewrite Cache-Control from a swappable NetworkStatusProvider. In production, back it with ConnectivityManager.
| Mode | Behavior |
|---|---|
WIFI |
public, max-age=0 — always revalidate |
CELLULAR |
public, max-age=60 — soft cache for one minute |
OFFLINE |
FORCE_CACHE + max-stale=2419200 (4 weeks) |
| Offline miss | OkHttp 504 → clear UI error |
override fun intercept(chain: Interceptor.Chain): Response {
val mode = networkStatusProvider.current()
var request = chain.request()
// Offline: force cache only; OkHttp returns 504 when nothing is cached
if (mode == NetworkMode.OFFLINE) {
request = request.newBuilder()
.cacheControl(CacheControl.FORCE_CACHE)
.build()
}
val originalResponse = chain.proceed(request)
val cacheControl = when (mode) {
NetworkMode.WIFI -> "public, max-age=0"
NetworkMode.CELLULAR -> "public, max-age=60"
NetworkMode.OFFLINE -> "public, only-if-cached, max-stale=2419200"
}
return originalResponse.newBuilder()
.removeHeader("Pragma")
.removeHeader("Cache-Control")
.header("Cache-Control", cacheControl)
.build()
}Install the interceptor on both the application and network chains so rewritten headers are stored on disk:
OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024))
.addInterceptor(cacheInterceptor) // FORCE_CACHE offline
.addInterceptor(authorizationInterceptor) // Bearer token
.addNetworkInterceptor(cacheInterceptor) // persist Cache-Control
.authenticator(tokenAuthenticator)
.build()TokenAuthenticator uses a dedicated refresh client without an Authenticator (no recursion):
- No refresh token / failed refresh → return
nulland stop - At most one authenticated retry
- Concurrent
401s share a single-flight refresh - If another call already refreshed → retry with the new token
override fun authenticate(route: Route?, response: Response): Request? {
// Give up after the original 401 + one authenticated retry
if (responseCount(response) >= 2) return null
val requestToken = extractBearerToken(
response.request.header("Authorization"),
)
synchronized(refreshLock) {
val currentAccess = tokenStore.getAccessToken()
// Another call already refreshed — retry with the new token
if (!currentAccess.isNullOrBlank() &&
requestToken != null &&
currentAccess != requestToken
) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentAccess")
.build()
}
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken.isNullOrBlank()) return null
// Dedicated client (no Authenticator), synchronous refresh
val refreshResponse = tokenRefreshApi
.refresh(RefreshTokenRequest(refreshToken))
.execute()
val body = refreshResponse.body()
if (!refreshResponse.isSuccessful || body == null) return null
tokenStore.saveTokens(body.accessToken, body.refreshToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${body.accessToken}")
.build()
}
}Inject the access token on every request:
class AuthorizationInterceptor(
private val tokenStore: TokenStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = tokenStore.getAccessToken()
if (accessToken.isNullOrBlank()) return chain.proceed(chain.request())
val authenticated = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(authenticated)
}
}Debug BODY logs redact Authorization. Release logging is off.
The UI should know more than success/failure. DemoRepository surfaces cache hits and token refresh:
suspend fun getProfile(): DemoOutcome<ProfileNetworkResult> {
val accessBefore = tokenStore.getAccessToken()
val response = demoApi.getProfile()
val raw = response.raw()
val tokenRefreshed = accessBefore != tokenStore.getAccessToken()
if (response.code() == 504) {
return DemoOutcome.Failure(DemoError.OfflineNoCache())
}
val source = when {
raw.networkResponse != null -> ResponseSource.NETWORK
raw.cacheResponse != null -> ResponseSource.CACHE
else -> ResponseSource.NETWORK
}
return DemoOutcome.Success(
ProfileNetworkResult(
body = response.body()!!,
source = source,
cacheMetadata = CacheMetadata(
cacheResponsePresent = raw.cacheResponse != null,
networkResponsePresent = raw.networkResponse != null,
sentRequestAtMillis = raw.sentRequestAtMillis,
receivedResponseAtMillis = raw.receivedResponseAtMillis,
cacheControl = raw.header("Cache-Control"),
),
tokenRefreshed = tokenRefreshed,
),
)
}The ViewModel owns StateFlow<DemoUiState>; Composables only render. Toggle network mode / expire the token in the demo to verify the recipes visually.
app/src/main/java/com/cheng/networkcookbook/
├── data/local/ # DataStore token store
├── data/network/ # Retrofit, OkHttp, cache, authenticator
├── data/repository/ # Domain outcomes
├── mock/ # Deterministic local API
└── ui/ # Compose + MVVM demo
This is a focused cookbook, not a drop-in production SDK. Swap the mock server, DTOs, and connectivity provider for your app — keep the retry limits and independent refresh client.
Why you can trust the samples
- Unit + MockWebServer tests for cache and authenticator
- Repository integration paths (online → offline cache, 401 → refresh)
- ViewModel + Compose UI smoke tests
- GitHub Actions: lint, unit tests,
assembleDebug - Apache-2.0 · no hardcoded production credentials
Started in 2016 as a Java / RxJava 1 / EventBus sample. v2 is a ground-up Kotlin + Compose rewrite: same valuable ideas (network-aware cache + token refresh), none of the obsolete stack or dead Azure API.
Contributions welcome — see CONTRIBUTING.md. Licensed under Apache-2.0.

