Android Networking
Retrofit, OkHttp, interceptors, kotlinx.serialization, error handling
- 01
What are Retrofit and OkHttp? How do they relate?
EasyOkHttp is the client that actually moves the bytes, and Retrofit is a typed API layer generated on top of it.
@Serializable data class UserDto(val id: Long, val name: String) interface UserApi { @GET("users/{id}") suspend fun getUser(@Path("id") id: Long): UserDto } … - 02
Application vs network interceptors in OkHttp — what is the difference, and what belongs in each?
MediumAn OkHttp
Interceptortakes aChainand returns aResponse, so it can observe, rewrite, retry or short-circuit any call.class AuthInterceptor(private val tokens: TokenStore) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .addHeader("Authorization", "Bearer ${tokens.access.orEmpty()}") .build() return chain.proceed(request) … - 03
How do you handle errors and typed responses with Retrofit?
MediumRetrofit throws on a non-2xx response when a suspend function returns the body type directly, so something has to catch.
sealed class AppError(message: String? = null) : Throwable(message) { object NetworkUnavailable : AppError() data class Server(val code: Int, val payload: String?) : AppError("HTTP $code") data class NotAuthorized(override val cause: Throwable? = null) : AppError("401") data class Contract(override val cause: Throwable) : AppError(cause.message) data class Unknown(override val cause: Throwable) : AppError(cause.message) … - 04
How do you cache HTTP responses on Android with OkHttp?
MediumOkHttp ships a built-in HTTP cache that respects standard
Cache-Controlheaders.val cacheDir = File(context.cacheDir, "http").apply { mkdirs() } val cache = Cache(directory = cacheDir, maxSize = 50L * 1024 * 1024) // 50 MB // Servers don't always send a useful Cache-Control — rewrite it on the way in val rewriteCache = Interceptor { chain -> chain.proceed(chain.request()).newBuilder() … - 05
How do you upload a file with Retrofit, and how do you report progress?
MediumRetrofit sends a multipart body when you mark the function
@Multipartand pass each field as a@Part.interface UploadApi { @Multipart @POST("upload") suspend fun upload( @Part("name") name: RequestBody, @Part file: MultipartBody.Part … - 06
Which real-time transport do you pick on Android — WebSocket, SSE or long-poll?
MediumWebSocket is full duplex, SSE is one-way from server to client, and long-poll is the fallback that costs the most battery.
// Socket as a Flow: opened on collect, closed when the collector goes away fun OkHttpClient.messages(url: String): Flow<String> = callbackFlow { val listener = object : WebSocketListener() { override fun onMessage(ws: WebSocket, text: String) { trySend(text) } override fun onClosed(ws: WebSocket, code: Int, reason: String) { close() } override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) { close(t) } … - 07
How do you configure kotlinx.serialization for an API you do not control?
MediumReal APIs send fields you never modelled, omit ones you expect and change shape without telling you, so the
Jsoninstance has to be configured for tolerance instead of left on its strict defaults.val json = Json { ignoreUnknownKeys = true // the backend WILL add fields explicitNulls = false // a missing key decodes to null or the default coerceInputValues = true // a null into a non-null property with a default } … - 08
Which timeouts does OkHttp give you, and what does it already retry on its own?
MediumOkHttp has four timeouts covering different stages of a call, and the only thing it retries by itself is a connection that failed before the server ever saw your request.
val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) // inactivity between bytes, not a total budget .writeTimeout(15, TimeUnit.SECONDS) .callTimeout(30, TimeUnit.SECONDS) // the only one that bounds the whole call .retryOnConnectionFailure(true) // default: another route, or a fresh connection … - 09
How do you test the networking layer without talking to a real server?
MediumPoint Retrofit at a
MockWebServeron localhost and assert both halves — the request your client actually sent and what your code did with the reply.class UserApiTest { private val server = MockWebServer() private lateinit var api: UserApi @BeforeTest fun setUp() { server.start() … - 10
How does Paging 3 work, and where does
RemoteMediatorfit when the list must also work offline?HardPaging 3 streams a
PagingDataof items produced by aPagingSource, andRemoteMediatoris the piece that refills a local database so the same list keeps working offline.// Network-only source: one page plus the keys either side class PostsPagingSource(private val api: PostsApi) : PagingSource<String, Post>() { override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> = try { val page = api.posts(cursor = params.key, limit = params.loadSize) LoadResult.Page(data = page.items, prevKey = null, nextKey = page.nextCursor) } catch (e: IOException) { … - 11
A list of remote images scrolls badly and memory climbs as the user goes down it. What is an image loader doing for you, and what do you still have to get right?
MediumAn image loader exists to decode at the size you actually display, cache the result in memory and on disk, and cancel a request the moment its target goes away.
@Composable fun Avatar(url: String, modifier: Modifier = Modifier) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(url) .crossfade(true) … - 12
How does the app know it is offline, and why is checking connectivity before every request the wrong instinct?
EasyConnectivity is observed through a
ConnectivityManagernetwork callback, but a request is never gated on it — a check can only tell you the call would probably have failed anyway.fun ConnectivityManager.statusFlow(): Flow<NetworkStatus> = callbackFlow { val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { trySend(NetworkStatus.Available) } override fun onLost(network: Network) { trySend(NetworkStatus.Lost) } override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) … - 13
A reviewer asks why every repository call is wrapped in
withContext(Dispatchers.IO)— does a Retrofitsuspendfunction need it?EasyNo — a Retrofit
suspendfunction never blocks the calling thread, so wrapping it inwithContext(Dispatchers.IO)buys nothing but an extra thread hop.interface UserApi { @GET("users/{id}") suspend fun getUser(@Path("id") id: Long): UserDto // suspends, never blocks @GET("users/{id}") fun getUserCall(@Path("id") id: Long): Call<UserDto> // blocking on execute() … - 14
The logging interceptor never prints the
Authorizationheader, and QA then finds access tokens in a release bug report — what went wrong on both counts?EasyBoth are the same interceptor misconfigured: it was added before the interceptor that attaches the header, and it was left above
Level.NONEin the release build.val logging = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE // never BODY in release redactHeader("Authorization") redactHeader("Cookie") } … - 15
The user backs out of a screen while a request is in flight — what happens to the call, and why does an error toast still pop up sometimes?
MediumviewModelScopeis cancelled inonCleared, Retrofit cancels the underlying OkHttpCall, and the toast appears because somebody caught theCancellationExceptionand rendered it as a failure.class UserViewModel(private val repo: UserRepository) : ViewModel() { // ❌ CancellationException lands in the generic catch: an error state for a dead screen fun loadWrong(id: Long) = viewModelScope.launch { try { _state.value = Loaded(repo.user(id)) } catch (e: Exception) { _state.value = Failed(e.message) } … - 16
Someone adds an interceptor that logs the response body, and now every call fails to parse with
IllegalStateException: closed— what is aResponseBodyreally?MediumA
ResponseBodyis a one-shot stream over the still-open socket, not a value: reading it consumes it,string()also closes it, and whoever comes next gets a closed stream.// ❌ consumes the body; Retrofit's converter then reads a closed stream val logBody = Interceptor { chain -> val response = chain.proceed(chain.request()) Log.d("api", response.body!!.string()) // downstream: IllegalStateException: closed response } … - 17
The base URL is
https://api.example.com/v2/but@GET("/users")hits/userswith no/v2in it — how does Retrofit build the final URL?MediumRetrofit resolves the annotation value against
baseUrlexactly the way a browser resolves anhref, so a leading slash means "from the root of the host" and discards the base path.val retrofit = Retrofit.Builder() .baseUrl("https://api.example.com/v2/") // must end in "/" .client(client) .build() interface Api { … - 18
You never wrote a class implementing your API interface, so what does
retrofit.create(Api::class.java)hand back, and when is a typo in an annotation found?MediumIt hands back a
java.lang.reflect.Proxyimplementing your interface, and by default a bad annotation blows up on the first call to that method rather than atcreate().val retrofit = Retrofit.Builder() .baseUrl("https://api.example.com/") .client(client) // factories are asked in order, first answer wins: Scalars before Json, or a // String return type gets handed to the JSON parser .addConverterFactory(ScalarsConverterFactory.create()) … - 19
A grid fires forty image requests at one host and a trace shows only five running, and on another screen every call stalls behind a live stream — what is going on?
HardOkHttp's
Dispatchercaps asynchronous calls at 64 in flight and 5 per host, and a long-lived streaming call holds one of those five host slots for as long as it lives.// Defaults: 64 in flight, 5 per host, applied to enqueue() — which is what every // Retrofit suspend function uses. Blocking execute() calls are counted, not throttled. val dispatcher = Dispatcher().apply { maxRequests = 64 maxRequestsPerHost = 15 // HTTP/2 multiplexes them onto one connection } … - 20
A 400 MB download OOMs on some phones and starts from zero every time the connection drops — how do you make it stream to disk and resume?
Hard@Streamingis what stops Retrofit buffering the body into memory, and aRangerequest measured against the bytes already on disk is what makes it resume.interface FilesApi { @Streaming // no buffering: you get the live stream @GET("files/{name}") suspend fun download( @Path("name") name: String, @Header("Range") range: String?, …