Retrofit & REST Explained
DATA › Networking
Retrofit turns an HTTP API into a plain Kotlin interface. You declare methods annotated with @GET, @POST, and the rest, and at runtime Retrofit generates a dynamic proxy that implements the interface: it builds an OkHttp request from the annotations and arguments, runs it through the OkHttp client, and hands the response to a converter that turns bytes into your model objects. None of that happens at compile time, the implementation is created reflectively when you call retrofit.create(ApiService::class.java).
Wiring the client itself is a Retrofit.Builder() chain. You must supply a baseUrl and at least one converter factory, since without a converter Retrofit has no idea how to turn JSON bytes into a Kotlin object. An OkHttpClient is optional, but in production code you almost always supply your own so you can add interceptors, timeouts, and logging.
There is one rule that trips people up constantly in interviews: baseUrl must end in a trailing slash. Retrofit resolves every method's path against it using standard URL rules, and if the trailing slash is missing, the builder refuses to build the client at all rather than silently doing the wrong thing.
Path resolution is where a lot of engineers get bitten in production, not just in interviews. Retrofit does not simply concatenate baseUrl and the string you pass to @GET, it resolves them using the same rules a browser uses for a relative link. If the annotation's path starts with a leading slash, it is treated as an absolute path against the host and it replaces everything after the domain, discarding any path segments your baseUrl carried, like a /v1/ prefix. Drop the leading slash and the path resolves relative to baseUrl instead, preserving that prefix.
The fix is simple once you know the rule: whenever your baseUrl carries a path segment, write your @GET, @POST, and other method paths without a leading slash, so they stay relative to it. Interviewers ask this because it is exactly the kind of bug that passes code review, compiles fine, and only shows up when a request silently hits the wrong endpoint.
Parameter annotations tell Retrofit where each argument of a method goes. @Path("id") substitutes a {id} placeholder inside the URL itself. @Query("key") appends it as a ?key=value query string parameter. @Header sets a single request header for that one call. None of those touch the request body, they only shape the URL or headers.
@Body is different: it hands an entire object straight to your converter factory to be serialized as the request payload, which is what you want for a JSON POST or PUT carrying a structured object like a new user. Mixing these up is a common mistake: reaching for @Query when you actually need to send a whole object as JSON just means Retrofit tries to stuff that object into the URL instead of the body, which does not compile for anything but primitives and strings.
A converter factory is what actually performs the translation between raw response bytes and your Kotlin model classes, and Retrofit does nothing useful without one registered. For kotlinx.serialization, you pull in the retrofit2-kotlinx-serialization-converter artifact and register a Json instance through Json.asConverterFactory, passing the media type it should handle. Every model class that flows through it needs to be marked @Serializable, or the compiler will not let you use it there.
Moshi and Gson follow the same pattern but with their own factories and their own model annotations: MoshiConverterFactory.create() expects Moshi's annotations, and GsonConverterFactory.create() works off Gson's reflection-based mapping. Retrofit only takes one converter factory chain, so pick one approach for your model layer and stick with it.
The return type of a suspend Retrofit method decides how failures reach you. Declare the method to return the body type directly, suspend fun getUser(): User, and Retrofit throws for you: HttpException for any non-2xx response, and a plain IOException if the network fails outright. That is convenient when a failure really is exceptional and you want to handle it with a single try/catch further up the call stack.
Declare it to return Response<T> instead, suspend fun getUser(): Response<User>, and Retrofit never throws for HTTP-level errors. You get the raw envelope back and inspect it yourself: isSuccessful, code(), body(), and errorBody(). So if a server sends back a 404 for a missing user and your function signature returns User directly, that call throws HttpException with code 404. It does not return null or an empty object, there is nothing to return because the deserialization to User never happens.
Once you are working with Response<T>, isSuccessful is the check you should reach for, and it is worth knowing exactly why the obvious alternatives are traps. isSuccessful is true for the entire 2xx range, 200 through 299, which is the actual definition of a successful HTTP response.
Checking code() == 200 specifically will miss a 201 Created from a successful POST, or a 204 No Content from a successful DELETE, both of which are correct outcomes that just are not exactly 200. And checking body() != null is worse: a 204 No Content response is successful by definition and correctly has a null body, so that check would flag a perfectly good response as a failure. isSuccessful is the one check that matches the real semantics of the status code range.
Some endpoints, classically OAuth token endpoints, expect application/x-www-form-urlencoded rather than JSON. Retrofit handles that with @FormUrlEncoded on the method combined with @Field on each parameter you want sent as a name equals value pair in the body. @FormUrlEncoded is what sets the Content-Type header and switches the encoding, without it @Field parameters have nowhere to go.
This is a different mechanism from @Body entirely: @Body serializes one structured object through your JSON converter, while @FormUrlEncoded plus @Field builds the body manually out of individual scalar parameters, no converter involved at all.
File uploads need a third mechanism again, multipart/form-data, and Retrofit models it with @Multipart on the method plus @Part on each parameter. Unlike @FormUrlEncoded, which only carries text fields, multipart parts can mix binary content like an image alongside ordinary text fields in the same request.
In practice you build the file part from a RequestBody: wrap the File with asRequestBody(mediaType), then wrap that in MultipartBody.Part.createFormData, giving it the field name the server expects, the filename, and the RequestBody. A raw File cannot be passed to @Body, the converter factory only knows how to serialize your JSON models, not arbitrary binary files, which is exactly why multipart needs its own annotation pair.
Every path you have seen so far is relative to baseUrl, but sometimes the server hands you a full URL to follow, a classic case being a pagination cursor link in a paged API response. Retrofit lets a method take that as a parameter annotated @Url, typed as String or HttpUrl, and uses it as the entire request URL instead of building one from baseUrl and the method's path.
An absolute URL passed through @Url bypasses baseUrl entirely, it does not get prefixed or merged with it. That is different from @Path, which only ever substitutes a single {placeholder} segment inside a path that is still resolved against baseUrl as usual.
Downloading a large file through a normal Retrofit method is a memory problem waiting to happen, because by default Retrofit buffers the entire response body before handing it to your converter. @Streaming on the method changes that: it tells Retrofit to leave the ResponseBody as an open stream rather than reading it all into memory up front, so you can read it incrementally, for example copying it straight to a file on disk.
@Streaming only changes how the response body is delivered, it has nothing to do with retries, compression, or parsing, those are separate concerns handled elsewhere. Pair it with Response<ResponseBody> as the return type so you get access to byteStream().
Real APIs need an Authorization header on nearly every call, and that header often carries a token that gets refreshed while the app is running. The cleanest place to add it is not inside every Retrofit method, it is an OkHttp Interceptor registered once on the client. An application interceptor sees every outgoing request before it hits the wire, so it can read whatever token you currently have and attach it as a header, and because it reads the token fresh on each call, a refresh takes effect automatically without touching a single endpoint definition.
Compare that to the alternatives: baking the token into baseUrl means rebuilding the whole Retrofit instance on every refresh, and adding a header parameter to every single method means updating dozens of interfaces every time the auth scheme changes. For the separate problem of reacting to a 401 and refreshing the token itself, OkHttp has a companion mechanism called an Authenticator, but attaching the header on every request belongs in an interceptor.
REST semantics give you a vocabulary interviewers expect you to use precisely, and it directly decides what you can safely retry. A method is idempotent if calling it once or ten times in a row leaves the server in exactly the same state, that covers GET, PUT, DELETE, and HEAD. A method is safe if it is read-only and never changes server state at all, a stricter guarantee than idempotence that covers only GET and HEAD. PUT and DELETE change state but are still idempotent because repeating the change does not compound it.
POST is neither. It is not idempotent, so if a network blip loses the response after the server actually processed a createUser call, blindly retrying can create a second user. That is the concrete production consequence: retry logic that treats every failed call the same way is a bug generator, it needs to know which methods are safe to repeat.
Not every failure deserves a retry. The transient ones are worth it: request timeouts (408), rate limiting (429), and server-side 5xx errors like 503 Service Unavailable, since these often resolve themselves if you wait and try again. Permanent 4xx errors like 400 Bad Request, 401 Unauthorized, or 404 Not Found will fail exactly the same way on the second attempt, since the problem is the request itself, not a transient condition, so retrying just wastes a round trip and can make an overloaded server's day worse.
When a server sends a 429 or 503, it will often include a Retry-After header telling you exactly how long to wait, either as a number of seconds or a target date. A well-behaved retry policy should honor that value rather than guessing its own delay, since ignoring it and hammering an already-overloaded or rate-limiting backend defeats the whole point of backing off.
Putting it together, a resilient retry strategy is exponential backoff, capped, jittered, and selective. Selective means only retrying transient failures on idempotent, or otherwise safe-to-repeat, calls, never a POST after an ambiguous timeout. Exponential means the delay between attempts grows, roughly doubling each time, so you back off rather than hammering a struggling server. Capped means you give up after a fixed number of attempts instead of retrying forever. Jitter means randomizing that delay slightly so that many clients that failed at the same moment do not all retry in lockstep and create a new spike, a pattern called a thundering herd.
The other half of resilient error handling is not retry logic at all, it is how you represent the outcome to the rest of the app. Modeling it as a sealed Result<T>, with a Success holding the data and an Error subtype, forces every caller to handle both branches explicitly through a when expression. Wrap the suspend call in try/catch, map a successful response to Success, and map HttpException or IOException to Error. That beats a bare try/catch scattered through the UI layer because a scattered try/catch can always be forgotten in one screen, a sealed type cannot be silently ignored.