Retrofit & REST Explained
DATA › Networking
**Retrofit turns an HTTP API into a Kotlin interface.** You declare methods annotated with @GET, @POST, and friends, and Retrofit generates the implementation that builds the request, runs it through OkHttp, and converts the response into your model:
interface ApiService {
@GET("users")
suspend fun getUsers(): List<User>
}
Building the client is a Retrofit.Builder() chain: a baseUrl, at least one converter factory to turn JSON bytes into Kotlin objects, and optionally a custom OkHttpClient:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
val api = retrofit.create(ApiService::class.java)
One easy-to-miss rule: baseUrl must end in a trailing slash, or build() throws.
**Path resolution is a classic gotcha.** Retrofit resolves the @GET or @POST path against baseUrl using standard URL rules: a path that starts with a leading / is treated as absolute and replaces everything after the host, discarding any path segments in baseUrl (like /v1/).
// baseUrl = "https://api.example.com/v1/"
@GET("users") // relative -> https://api.example.com/v1/users
@GET("/users") // absolute -> https://api.example.com/users, /v1/ is lost
If your baseUrl carries a path prefix, always write the @GET or @POST path without a leading slash so it resolves relative to that prefix instead of replacing it.
**Parameter annotations describe where each argument goes.** @Path("id") substitutes a {id} placeholder in the URL, @Query("key") appends ?key=value, and @Body hands an object to the converter to serialize as the request payload:
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): User
@GET("users")
suspend fun search(@Query("q") term: String): List<User>
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): User
}
There's also @Header for a single request header, and @HeaderMap or @QueryMap when you have a variable set of them at call time.
**suspend fun return type changes how errors surface.** Return the body type directly and Retrofit throws for you: HttpException on any non-2xx response, IOException on a network failure. Return Response<T> instead and Retrofit never throws for HTTP errors, you inspect the result yourself:
// Throws on non-2xx
suspend fun getUser(id: Int): User
// Never throws for HTTP errors, you check it
suspend fun getUserSafe(id: Int): Response<User>
val response = api.getUserSafe(id)
if (response.isSuccessful) {
val user = response.body()
} else {
val err = response.errorBody()?.string()
}
Use Response<T> when you need the status code or errorBody, and a try/catch around the direct-body call otherwise.
**Wiring a converter is what makes the translation between JSON and Kotlin happen.** For kotlinx.serialization, add the retrofit2-kotlinx-serialization-converter dependency and register a Json instance as a converter factory; your models must be @Serializable:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
@Serializable
data class User(val id: Int, val name: String)
Moshi and Gson use their own factories, MoshiConverterFactory.create() and GsonConverterFactory.create(), each pulling in that library's model annotations instead.
**REST semantics decide what's safe to retry.** GET, PUT, DELETE, and HEAD are idempotent, calling them twice leaves the server in the same state as calling them once, so a network blip is safe to retry. POST is not idempotent: retrying a createUser call after a timeout can create two users if the first request actually succeeded server-side but the response was lost.
@GET("users/{id}") suspend fun getUser(@Path("id") id: Int): User // safe to retry
@POST("users") suspend fun createUser(@Body u: User): User // NOT safe to retry blindly
GET and HEAD are additionally 'safe', meaning they must not change server state at all, a stricter guarantee than idempotence.
**Modeling outcomes with a sealed Result<T>** keeps error handling out of scattered try/catch blocks and forces callers to handle every case:
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Error(val code: Int?, val message: String) : ApiResult<Nothing>
}
suspend fun <T> safeCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Success(block())
} catch (e: HttpException) {
ApiResult.Error(e.code(), e.message())
} catch (e: IOException) {
ApiResult.Error(null, "network error")
}
Callers then when over the sealed type, and the compiler flags any unhandled branch, which is exactly the safety net you lose with a bare try/catch scattered through the UI layer.
**A resilient retry policy is exponential backoff, capped, and selective.** Only retry transient conditions, timeouts, 408, 429, and 5xx, never permanent 4xx errors like 400 or 404 which will fail the same way every time. Increase the delay between attempts, add jitter to avoid thundering-herd retries, and cap the attempt count:
fun isRetryable(code: Int) = code in setOf(408, 429, 500, 502, 503, 504)
suspend fun <T> withBackoff(maxAttempts: Int = 3, block: suspend () -> T): T {
repeat(maxAttempts - 1) { attempt ->
try { return block() } catch (e: IOException) { /* retry */ }
delay((2.0.pow(attempt) * 1000).toLong())
}
return block() // last attempt, let it throw
}
And when a server sends Retry-After on a 429 or 503, honor it instead of your own backoff schedule, it's telling you exactly how long to wait.