Android Nomad #55 - Implementing Rate Limiters

Ensuring Optimal Performance and Preventing Overload with Effective Rate Limiting Strategies

Android Nomad #55 - Implementing Rate Limiters

In a world where API efficiency and user experience are paramount, implementing rate limiters in Android apps is a crucial skill. Rate limiters help prevent excessive API calls, ensuring app performance, reducing costs, and adhering to server restrictions.


What is Rate Limiting?

Rate limiting is a strategy used to control the frequency of API requests made by a client within a specific time frame. It prevents scenarios like server overload, accidental denial-of-service (DoS) attacks, or API quota breaches. Commonly, APIs enforce rate limits such as "100 requests per minute," but app developers can also enforce limits locally for various use cases:

  1. Avoiding redundant requests due to repeated user actions.
  2. Protecting against rogue processes causing high-frequency requests.
  3. Ensuring adherence to server-side policies.

Key Types of Rate Limiters

  1. Fixed Window: Limits requests within a fixed time interval (e.g., 100 requests per minute).
  2. Sliding Window: Tracks requests over a sliding time window to smooth out traffic spikes.
  3. Token Bucket: Tokens are added at a steady rate, and each request consumes a token. If no tokens remain, the request is denied.
  4. Leaky Bucket: Similar to token bucket but ensures a fixed rate of processing requests regardless of bursts.

Implementing a Rate Limiter in an Android App

Here’s how you can implement a simple token bucket rate limiter for an Android app using Kotlin:

This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersShow hidden characters

class ApiService(
private val rateLimiter: RateLimiter,
private val analyticsService: AnalyticsService
) {
suspend fun logEvent(event: AnalyticsEvent): Response<Data>? {
return withContext(Dispatchers.IO) {
if (rateLimiter.execute()) {
try {
analyticsService.postEvent(event)
} catch (e: Exception) {
Log.e("ApiService", "Error fetching data", e)
null
}
} else {
Log.d("ApiService", "Rate limit exceeded")
null // Rate limit exceeded
}
}
}
}

view raw ApiService.kt hosted with ❤ by GitHubThis file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersShow hidden characters

/**
* @param scope - Coroutine scope to manage background tasks
* @param maxTokens - Maximum number of allowed events (tokens)
* @param rateWindowMillis - Time window for token refill (in milliseconds)
* @param callback - Action to perform when an event is allowed
* @param onExpire - Action when rate limiter expires
* @param EXPIRATION_DURATION - Time after which limiter expires (e.g., 15 seconds)
*/
internal class RateLimiter(
private val scope: CoroutineScope,
private val maxTokens: Int,
private val rateWindowMillis: Long,
private val callback: () -> Unit = {},
private val onExpire: () -> Unit = {},
private val EXPIRATION_DURATION: Long = 15000L
) {
private var refillCollection: Job // Job to handle token collection
private var refillEmission: Job // Job to handle periodic token refill
private var tokens: AtomicInteger = AtomicInteger(maxTokens) // Current token count, thread-safe
private val _refillFlow = MutableSharedFlow<Unit>() // Flow to emit refill events
val refillFlow = _refillFlow.asSharedFlow() // Expose the refill flow as read-only
// Initialization block, starts token refill and token collection jobs
init {
// Emission job refills tokens at regular intervals, stops after expiration duration
refillEmission = scope.launch {
for (i in 0..ceil(EXPIRATION_DURATION.toDouble() / rateWindowMillis).toInt()) {
delay(rateWindowMillis) // Wait for the rate window
_refillFlow.emit(Unit) // Emit token refill event
}
stop() // Stop refilling after expiration
}
// Collection job listens for refill events and refills tokens
refillCollection = scope.launch {
refillFlow.collect {
refillTokens() // Refill the tokens when a new event is collected
}
}
}
// Refills the token to the maximum count
private fun refillTokens() {
tokens.set(maxTokens)
}
// Called to execute an event if tokens are available
fun execute() {
if (tokens.getAndDecrement() > 0) {
callback.invoke() // Trigger event if tokens are available
} else {
Log.d("RateLimiter", "Limit exceeded") // Log if the rate limit is exceeded
}
}
// Stops the rate limiter when expired
fun stop() {
onExpire.invoke() // Trigger onExpire callback
refillEmission.cancel() // Cancel the emission job
refillCollection.cancel() // Cancel the collection job
}
}

view raw RateLimiter.kt hosted with ❤ by GitHub


Handle Rate-Limiting Errors Gracefully

If you’ve implemented server-side rate limiting, handle 429 Too Many Requests responses:

suspend fun fetchDataWithRetry(): Response<Data>? {
    var response: Response<Data>?
    var retryCount = 0

    do {
        response = fetchData()
        if (response?.code() == 429) {
            val retryAfter = response.headers()["Retry-After"]?.toLongOrNull() ?: 1000
            delay(retryAfter)
        }
        retryCount++
    } while (response?.code() == 429 && retryCount < MAX_RETRIES)

    return response
}

Best Practices for Using Rate Limiters

  1. Use Dependency Injection (DI):

    • Integrate the RateLimiter with DI frameworks like Hilt or Dagger to ensure a singleton instance across the app.

    • Example with Hilt:

      @Module
      @InstallIn(SingletonComponent::class)
      object RateLimiterModule {
          @Provides
          @Singleton
          fun provideRateLimiter(): RateLimiter {
              return RateLimiter(
                  scope = CoroutineScope(Dispatchers.IO),
                  maxTokens = 10,
                  rateWindowMillis = 1000L
              )
          }
      }
  2. Centralize Configuration:

    • Define rate limiter parameters (e.g., maxTokens, rateWindowMillis) in a configuration file or constants class for easier updates and consistency.

  3. Thread Safety:

    • Ensure the RateLimiter instance is thread-safe, especially when accessed from multiple parts of the app.

  4. Testability:

    • Mock the RateLimiter in unit tests to simulate rate-limiting scenarios.

  5. Scalability:

    • Use different rate limiter instances for different API endpoints if they have distinct rate limits.

  6. Performance Monitoring:

    • Log rate limiter usage and failures to analyze patterns and optimize settings.


Conclusion

Rate limiters are essential for building robust Android apps that efficiently manage network resources while providing seamless user experiences. By implementing a rate limiter, you can optimize app performance, prevent server overload, and maintain compliance with API policies. Whether you’re using a simple token bucket or integrating advanced server-side strategies, rate limiting is a cornerstone of modern app development.

Subscribe to Sid Pillai

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe