10 Retrofit Mistakes That Silently Break Your App
Retrofit is the most used networking library on Android. It’s also the easiest to misuse. These 10 mistakes don’t crash your app — they…
10 Retrofit Mistakes That Silently Break Your App
Retrofit is the most used networking library on Android. It’s also the easiest to misuse. These 10 mistakes don’t crash your app — they silently cause stale data, double requests, token leaks, and API failures that only show up in production when real users on slow networks with expiring tokens hit edge cases your emulator never did.

Mistake 1: Hardcoding Auth Tokens (Not Using Interceptors)
The Problem
Without an interceptor, every API call must manually pass the token. Forget it once → 401. Token changes (refresh) → update every call site. Multiple API interfaces → duplicate token logic everywhere.
// ❌ Every call needs the token — fragile, duplicated, error-prone
interface TransferApi {
@GET("transfers")
suspend fun getTransfers(@Header("Authorization") token: String): Response<List<TransferDto>>
}
interface AccountApi {
@GET("accounts")
suspend fun getAccounts(@Header("Authorization") token: String): Response<List<AccountDto>>
}
// Usage - must pass token EVERY TIME:
val transfers = transferApi.getTransfers("Bearer $token")
val accounts = accountApi.getAccounts("Bearer $token")
// Forgot the token in one call? Silent 401 failure.
// Token refreshed? Must update every call site manually.
The Fix
// ✅ OkHttp Interceptor adds token to EVERY request automatically
class AuthInterceptor @Inject constructor(
private val tokenStorage: TokenStorage
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStorage.getAccessToken()
val request = if (token != null) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
// API interfaces are clean - no @Header("Authorization")
interface TransferApi {
@GET("transfers")
suspend fun getTransfers(): Response<List<TransferDto>>
}
interface AccountApi {
@GET("accounts")
suspend fun getAccounts(): Response<List<AccountDto>>
}
// Usage - token is added automatically:
val transfers = transferApi.getTransfers() // Auth header injected by interceptor
Mistake 2: No 401 Token Refresh (User Gets Logged Out)
The Problem
Access tokens expire — typically after 15–60 minutes. Without automatic refresh, every expired token results in a 401 → user sees “Session expired” → must log in again → terrible UX. Banking apps where sessions expire during a transfer flow are especially painful.
// ❌ 401 → show error → user logs in again → loses their in-progress work
repository.getTransfers() // Token expired → 401 → "Please log in again"
// User was filling a transfer form → all data LOST
// ✅ FIX: OkHttp Authenticator - auto-refreshes and retries
class TokenAuthenticator @Inject constructor(
private val tokenStorage: TokenStorage,
private val authApi: Provider<AuthApi> // Provider to avoid circular dependency with Retrofit
) : Authenticator {
private val refreshLock = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
// Don't retry if this is already a retry (prevent infinite loop)
if (response.request.header("X-Retry") != null) return null
// Don't retry auth endpoints (login, refresh)
if (response.request.url.encodedPath.contains("auth/")) return null
// Synchronize refresh to prevent multiple simultaneous refresh calls
return runBlocking {
refreshLock.withLock {
// Check if another thread already refreshed
val currentToken = tokenStorage.getAccessToken()
val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ")
if (currentToken != null && currentToken != requestToken) {
// Token was already refreshed by another request - just retry with new token
return@runBlocking response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.header("X-Retry", "true")
.build()
}
// Actually refresh the token
try {
val refreshToken = tokenStorage.getRefreshToken() ?: return@runBlocking null
val refreshResponse = authApi.get().refreshToken(RefreshRequest(refreshToken))
if (refreshResponse.isSuccessful) {
val newTokens = refreshResponse.body()!!
tokenStorage.saveTokens(newTokens.accessToken, newTokens.refreshToken)
response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.header("X-Retry", "true")
.build()
} else {
// Refresh token also expired → force logout
tokenStorage.clearTokens()
null // Returning null = don't retry → 401 propagates to caller
}
} catch (e: Exception) {
null
}
}
}
}
}
// Wire both:
val client = OkHttpClient.Builder()
.addInterceptor(authInterceptor) // Adds token to every request
.authenticator(tokenAuthenticator) // Refreshes token on 401
.build()
Mistake 3: No Timeouts (Request Hangs Forever)
// ❌ Default OkHttp timeouts are lenient — can hang for 30+ seconds
// Default connect: 10s, read: 10s, write: 10s
// On slow networks: user stares at a loading spinner for 30 seconds
// ✅ FIX: Set aggressive but reasonable timeouts
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) // How long to establish TCP connection
.readTimeout(30, TimeUnit.SECONDS) // How long to wait for response data
.writeTimeout(30, TimeUnit.SECONDS) // How long to wait for request upload
.callTimeout(60, TimeUnit.SECONDS) // Total time for the ENTIRE call (incl. redirects)
.build()
// For specific slow endpoints (file upload), override per-call:
interface FileApi {
@Multipart
@POST("documents/upload")
suspend fun uploadDocument(
@Part file: MultipartBody.Part
): Response<UploadResult>
}
// OkHttp allows per-request timeouts via a new client:
val uploadClient = client.newBuilder()
.writeTimeout(120, TimeUnit.SECONDS) // 2 minutes for large file uploads
.build()
Mistake 4: Logging Sensitive Data in Production
// ❌ HttpLoggingInterceptor logs EVERYTHING — tokens, passwords, card numbers
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// Logcat shows:
// --> POST https://api.mybank.com/auth/login
// Content-Type: application/json
// {"email":"user@bank.com","password":"MySecret123!"} ← PASSWORD IN LOGCAT
// <-- 200 OK
// {"accessToken":"eyJhbG...","refreshToken":"dGhpcyBpcyBh..."} ← TOKENS IN LOGCAT
// On shared devices or with ADB access: security breach
// ✅ FIX: Log level NONE in release, BODY only in debug
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE // Zero logging in production
}
}
// ✅ BETTER: Custom logger that redacts sensitive fields
val logging = HttpLoggingInterceptor { message ->
val redacted = message
.replace(Regex(""""password"\s*:\s*"[^"]*""""), """"password":"***"""")
.replace(Regex(""""accessToken"\s*:\s*"[^"]*""""), """"accessToken":"***"""")
.replace(Regex(""""cardNumber"\s*:\s*"[^"]*""""), """"cardNumber":"***"""")
Log.d("OkHttp", redacted)
}.apply { level = HttpLoggingInterceptor.Level.BODY }
Mistake 5: Creating Multiple Retrofit Instances
// ❌ New Retrofit instance on every call — no connection pooling, no cache
class TransferRepository {
fun getApi(): TransferApi {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(TransferApi::class.java)
}
suspend fun getTransfers() = getApi().getTransfers() // New Retrofit + OkHttpClient EACH TIME
}
// Each call creates: new Retrofit, new OkHttpClient, new connection pool, new cache
// HTTP/2 multiplexing impossible (different clients don't share connections)
// Memory waste: OkHttp connection pool objects leaked every time
// ✅ FIX: Singleton via Hilt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
@ApplicationContext context: Context
): OkHttpClient {
val cache = Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)
return OkHttpClient.Builder()
.cache(cache)
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build()
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.mybank.com/v1/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(
Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
))
.build()
}
@Provides
@Singleton
fun provideTransferApi(retrofit: Retrofit): TransferApi =
retrofit.create(TransferApi::class.java)
}
// ONE OkHttpClient → shared connection pool → HTTP/2 multiplexing
// ONE Retrofit → shared converter, consistent config
Mistake 6: Ignoring Error Response Bodies
// ❌ Only checking isSuccessful — missing server validation messages
suspend fun createTransfer(request: CreateTransferRequest): Resource<Transfer> {
val response = api.createTransfer(request)
return if (response.isSuccessful) {
Resource.Success(response.body()!!.toDomain())
} else {
Resource.Error("Something went wrong")
// User sees: "Something went wrong"
// Server sent: {"error": "Insufficient funds", "field": "amount", "min_balance": "100.00"}
// The user has NO IDEA what to fix
}
}
// ✅ FIX: Parse the error body for meaningful messages
suspend fun createTransfer(request: CreateTransferRequest): Resource<Transfer> {
val response = api.createTransfer(request)
return if (response.isSuccessful) {
Resource.Success(response.body()!!.toDomain())
} else {
val errorBody = response.errorBody()?.string()
val apiError = try {
moshi.adapter(ApiErrorResponse::class.java).fromJson(errorBody ?: "")
} catch (e: Exception) { null }
when (response.code()) {
400 -> Resource.Error(AppError.Validation(
message = apiError?.message ?: "Invalid request",
fieldErrors = apiError?.fieldErrors ?: emptyMap()
))
401 -> Resource.Error(AppError.Unauthorized)
403 -> Resource.Error(AppError.Forbidden(apiError?.message ?: "Access denied"))
404 -> Resource.Error(AppError.NotFound)
422 -> Resource.Error(AppError.BusinessLogic(
apiError?.message ?: "Operation not allowed"
))
429 -> Resource.Error(AppError.RateLimited(
retryAfterSeconds = response.headers()["Retry-After"]?.toIntOrNull()
))
in 500..599 -> Resource.Error(AppError.Server(
apiError?.message ?: "Server error. Please try again later."
))
else -> Resource.Error(AppError.Unknown("Error ${response.code()}"))
}
}
}
@JsonClass(generateAdapter = true)
data class ApiErrorResponse(
val message: String?,
val code: String?,
val fieldErrors: Map<String, String>?
)
Mistake 7: Manual URL Building (Not Using @Query)
// ❌ Manually building URLs — doesn't encode special characters
@GET
suspend fun search(@Url url: String): Response<SearchResult>
// Called as:
api.search("search?q=hello world&page=1&status=pending")
// Space in "hello world" is NOT URL-encoded → request fails or returns wrong results
// Special characters (&, =, #, ?) in query values break the URL
// ✅ FIX: Use @Query - Retrofit handles encoding automatically
@GET("search")
suspend fun search(
@Query("q") query: String, // URL-encoded automatically
@Query("page") page: Int = 1, // Default value
@Query("status") status: String? = null, // null → parameter omitted entirely
@Query("sort") sort: String = "date_desc"
): Response<SearchResult>
// Usage:
api.search(query = "hello world", page = 2, status = "pending")
// Retrofit generates: search?q=hello%20world&page=2&status=pending&sort=date_desc
// "hello world" → "hello%20world" (correctly encoded)
Mistake 8: No Offline Cache
// ❌ App shows blank screen or error when offline
// Default Retrofit has NO cache — every request hits the network
// ✅ FIX: OkHttp cache + offline interceptor
class OfflineCacheInterceptor(
private val context: Context
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (!isNetworkAvailable(context)) {
// When offline: serve from cache, even if stale
request = request.newBuilder()
.cacheControl(
CacheControl.Builder()
.maxStale(7, TimeUnit.DAYS) // Accept week-old cache
.build()
)
.build()
}
return chain.proceed(request)
}
private fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
// Server-side cache control (add as NETWORK interceptor):
class CacheControlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
return response.newBuilder()
.header("Cache-Control", "public, max-age=300") // Cache 5 minutes
.removeHeader("Pragma")
.build()
}
}
val client = OkHttpClient.Builder()
.cache(Cache(File(context.cacheDir, "http_cache"), 50 * 1024 * 1024))
.addInterceptor(OfflineCacheInterceptor(context)) // App-level interceptor
.addNetworkInterceptor(CacheControlInterceptor()) // Network-level interceptor
.build()
Mistake 9: Suspend Functions Returning Response<T> Everywhere
// ❌ Every repository function returns Response<T> — boilerplate everywhere
class TransferRepository(private val api: TransferApi) {
suspend fun getTransfers(): Response<List<TransferDto>> = api.getTransfers()
}
// Every ViewModel must handle Response wrapping:
viewModelScope.launch {
val response = repository.getTransfers()
if (response.isSuccessful) {
_state.value = response.body()!!.map { it.toDomain() }
} else {
// Parse error AGAIN in every ViewModel
}
}
// ✅ FIX: Repository maps to domain Result type - ViewModel is clean
class TransferRepository @Inject constructor(private val api: TransferApi) {
suspend fun getTransfers(): Resource<List<Transfer>> = safeApiCall {
api.getTransfers().map { it.toDomain() }
}
}
// Reusable safe-call wrapper:
suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
return try {
Resource.Success(call())
} catch (e: CancellationException) { throw e }
catch (e: IOException) { Resource.Error(AppError.Network) }
catch (e: HttpException) { Resource.Error(mapHttpError(e)) }
catch (e: Exception) { Resource.Error(AppError.Unknown(e.message)) }
}
// ViewModel - clean:
val transfers = safeApiCall { repository.getTransfers() }
when (transfers) {
is Resource.Success -> _state.value = UiState.Success(transfers.data)
is Resource.Error -> _state.value = UiState.Error(transfers.error)
}
Mistake 10: Base URL Without Trailing Slash
// ❌ Missing trailing slash — Retrofit SILENTLY removes path segments
Retrofit.Builder()
.baseUrl("https://api.mybank.com/v1") // NO trailing slash!
.build()
// @GET("transfers") resolves to: https://api.mybank.com/transfers
// v1 is REMOVED! The path component "v1" is replaced by "transfers"
// because "v1" is treated as a file, not a directory
// ✅ FIX: ALWAYS end base URL with /
Retrofit.Builder()
.baseUrl("https://api.mybank.com/v1/") // Trailing slash!
.build()
// @GET("transfers") resolves to: https://api.mybank.com/v1/transfers ✅
// "v1/" is treated as a directory - "transfers" is appended correctly
// THE RULE: Base URLs MUST end with /
// Endpoint paths MUST NOT start with / (unless you want to replace the entire path)
// @GET("transfers") → https://api.mybank.com/v1/transfers ✅
// @GET("/transfers") → https://api.mybank.com/transfers ❌ (absolute path - ignores base)
Complete OkHttpClient Setup (Production-Ready)
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
offlineCacheInterceptor: OfflineCacheInterceptor,
@ApplicationContext context: Context
): OkHttpClient {
return OkHttpClient.Builder()
// Cache
.cache(Cache(File(context.cacheDir, "http_cache"), 50 * 1024 * 1024))
// Auth
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
// Offline support
.addInterceptor(offlineCacheInterceptor)
// Timeouts
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
// Debug logging (redacted in release)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
// Certificate pinning
.certificatePinner(
CertificatePinner.Builder()
.add("api.mybank.com", "sha256/xxxxx=")
.add("api.mybank.com", "sha256/yyyyy=")
.build()
)
.build()
}
Connect with Me on LinkedIn
Follow me on LinkedIn
Tags: #Retrofit #OkHttp #Android #Networking #Kotlin #API #Authentication #Caching #Production #BestPractices
메타데이터
- post_id
- 89d37b016ab2
- slug
- 10-retrofit-mistakes-that-silently-break-your-app-89d37b016ab2
- url
- https://medium.com/@ramadan123sayed/10-retrofit-mistakes-that-silently-break-your-app-89d37b016ab2
- canonical_url
- https://medium.com/@ramadan123sayed/10-retrofit-mistakes-that-silently-break-your-app-89d37b016ab2
- author_url
- https://medium.com/@ramadan123sayed
- status
- ok
- fetched_at
- 2026-08-12 11:48:19