Flaky networks are a fact of mobile life. Robust apps retry intelligently — without hammering the server or creating duplicate operations. This exercise builds a production-grade retry layer.
Step 1: Sealed Result Type
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int?, val message: String) : NetworkResult<Nothing>()
object NetworkError : NetworkResult<Nothing>() // no connectivity
}
Step 2: OkHttp Retry Interceptor
class RetryInterceptor(
private val maxRetries: Int = 3,
private val initialDelayMs: Long = 500
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var response: Response? = null
var attempt = 0
var lastException: IOException? = null
while (attempt <= maxRetries) {
try {
response?.close()
response = chain.proceed(request)
if (response.isSuccessful || !shouldRetry(response.code)) {
return response
}
// Server error — retry with backoff
response.close()
} catch (e: IOException) {
lastException = e
if (!isRetryableException(e)) throw e
}
if (attempt < maxRetries) {
val delayMs = initialDelayMs * (2.0.pow(attempt)).toLong() + jitter()
Thread.sleep(delayMs)
}
attempt++
}
return response ?: throw lastException ?: IOException("Max retries exceeded")
}
private fun shouldRetry(code: Int) = code in 500..599 || code == 429
private fun isRetryableException(e: IOException) =
e is SocketTimeoutException || e is ConnectException
private fun jitter() = (Math.random() * 200).toLong() // up to 200ms random jitter
}
Step 3: Idempotency Keys for POST Requests
Without idempotency keys, retrying a POST may create duplicate records (e.g., double charges, duplicate orders).
class IdempotencyInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
// Only add idempotency key to mutating requests
if (original.method !in listOf("POST", "PUT", "PATCH")) {
return chain.proceed(original)
}
val request = original.newBuilder()
.header("Idempotency-Key", UUID.randomUUID().toString())
.build()
return chain.proceed(request)
}
}
For even better idempotency, derive the key from the request content so retries use the same key:
fun generateIdempotencyKey(body: String): String =
UUID.nameUUIDFromBytes(body.toByteArray()).toString()
Step 4: Kotlin Flow retryWhen
For repository-level retries with Flow:
fun getArticles(): Flow<List<Article>> = flow {
val articles = api.getArticles()
emit(articles)
}
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(500L * (2.0.pow(attempt.toInt())).toLong())
true // retry
} else {
false // don't retry
}
}
.catch { e ->
emit(emptyList()) // fallback
}
Step 5: Unit Test with MockWebServer
class RetryInterceptorTest {
private val mockWebServer = MockWebServer()
private val client = OkHttpClient.Builder()
.addInterceptor(RetryInterceptor(maxRetries = 2, initialDelayMs = 10))
.build()
@Before fun setUp() = mockWebServer.start()
@After fun tearDown() = mockWebServer.shutdown()
@Test fun `retries on 500 then succeeds`() {
// First two calls return 500, third succeeds
mockWebServer.enqueue(MockResponse().setResponseCode(500))
mockWebServer.enqueue(MockResponse().setResponseCode(500))
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val request = Request.Builder().url(mockWebServer.url("/test")).build()
val response = client.newCall(request).execute()
assertEquals(200, response.code)
assertEquals(3, mockWebServer.requestCount)
}
@Test fun `does not retry on 400 (client error)`() {
mockWebServer.enqueue(MockResponse().setResponseCode(400))
val request = Request.Builder().url(mockWebServer.url("/test")).build()
val response = client.newCall(request).execute()
assertEquals(400, response.code)
assertEquals(1, mockWebServer.requestCount) // no retry
}
}
Key Takeaways
| Concept | Rule |
|---|---|
| Retry on 5xx | Server errors are transient; client errors (4xx) are not |
| Exponential backoff | Double the delay each retry to avoid thundering herd |
| Jitter | Add random delay to prevent synchronized retries across clients |
| Idempotency key | Required for POST/PUT to prevent duplicate side effects |
| Max retries | 3 is usually sufficient; log when max is reached |