androidengineers.Book a session

Network Architecture

Exercise: Retry/Backoff/Idempotency

exercise50 minHard

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

ConceptRule
Retry on 5xxServer errors are transient; client errors (4xx) are not
Exponential backoffDouble the delay each retry to avoid thundering herd
JitterAdd random delay to prevent synchronized retries across clients
Idempotency keyRequired for POST/PUT to prevent duplicate side effects
Max retries3 is usually sufficient; log when max is reached

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Retry/Backoff/Idempotency | Android System Design | Android Engineers