Когда ваш API кричит "SOS": Знакомство с Rate Limiting и Throttling
Представьте: вы написали идеальное Kotlin-приложение. Оно быстрое, элегантное, использует корутины и Flow. Вы выложили его в прод, и тут — бац! — один "добрый" пользователь (или, что хуже, злоумышленник) начинает долбить ваш эндпоинт тысячами запросов в секунду. Сервер падает, база данных плачет, а остальные пользователи получают 503 Service Unavailable.
Знакомая боль? Именно для этого существуют механики rate limiting (ограничение скорости) и throttling (дросселирование). В этой статье мы не просто поговорим о теории, а на практических примерах разберем, как внедрить эти паттерны в Kotlin.
Мы будем писать код, который реально защитит ваш бэкенд, будь то приложение на Ktor, Spring Boot или чистая корутина. Поехали!
Rate Limiting vs Throttling: В чем разница (и почему это важно)?
Начнем с базы, чтобы не путаться в терминах. Хотя эти понятия часто используют как синонимы, между ними есть нюанс.
- Rate Limiting — это жесткий лимит. "Ты можешь сделать только 100 запросов в минуту. После 101-го — получишь
429 Too Many Requestsи бан до следующей минуты". Это про лимит. - Throttling — это более мягкое регулирование. "Ты не можешь слать запросы быстрее, чем 1 раз в 200 мс. Если слать быстрее — мы их поставим в очередь или просто проигнорируем". Это про скорость.
В мире Kotlin мы чаще всего реализуем rate limiting на уровне API (middleware) и throttling на уровне конкретных операций (например, чтобы не спамить внешний сервер запросами).
Алгоритмы, которые должен знать каждый Kotlin-разработчик
Прежде чем писать код, давайте выберем инструмент. Есть три классических алгоритма, которые идеально ложатся на Kotlin-архитектуру.
1. Token Bucket (Ведро с токенами) — Самый популярный
Представьте ведро, в которое каждую секунду капает N новых токенов. У ведра есть максимальный объем (burst). Чтобы обработать запрос, нужно взять токен. Если токенов нет — запрос отклоняется.
Плюсы: Позволяет небольшие всплески нагрузки (bursts), прост в реализации.
2. Leaky Bucket (Дырявое ведро) — Сглаживание пиков
Ведро с дыркой. Запросы попадают в ведро, а "дырка" выпускает их с постоянной скоростью. Если ведро переполняется — запросы отбрасываются.
Плюсы: Идеально выравнивает нагрузку, не дает пиков.
3. Sliding Window (Скользящее окно) — Точность без рывков
Вместо жестких границ (как в "1 минута"), мы смотрим на скользящий отрезок времени. Сложнее в реализации, но точнее отражает реальную нагрузку.
Для Kotlin-проектов Token Bucket — золотая середина между простотой и эффективностью. Его мы и реализуем.
Пишем код: Rate Limiter на Kotlin с нуля (без библиотек)
Давайте создадим простой, но рабочий Rate Limiter, используя корутины и Mutex. Это будет потокобезопасная версия Token Bucket.
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.TimeSource
class TokenBucketRateLimiter(
private val maxTokens: Int,
private val refillRate: Long, // токенов в секунду
private val timeSource: TimeSource = TimeSource.Monotonic
) {
private var tokens: Long = maxTokens.toLong()
private var lastRefillTimestamp: Long = timeSource.markNow().elapsedNow().inWholeMilliseconds
private val mutex = Mutex()
suspend fun tryAcquire(): Boolean {
mutex.withLock {
refillTokens()
return if (tokens > 0) {
tokens--
true
} else {
false
}
}
}
private fun refillTokens() {
val now = timeSource.markNow().elapsedNow().inWholeMilliseconds
val elapsed = now - lastRefillTimestamp
val tokensToAdd = (elapsed * refillRate) / 1000
tokens = (tokens + tokensToAdd).coerceAtMost(maxTokens.toLong())
lastRefillTimestamp = now
}
}
Как это работает:
maxTokens— максимальное количество токенов (burst).refillRate— сколько токенов добавляется в секунду.tryAcquire()— потокобезопасный метод, который возвращаетtrue, если запрос можно обработать.
Теперь используем это в простом принтере (симуляция запросов):
import kotlinx.coroutines.*
fun main() = runBlocking {
val limiter = TokenBucketRateLimiter(maxTokens = 5, refillRate = 2) // 5 токенов, +2 в секунду
val jobs = List(10) { id ->
launch {
delay((id * 100).toLong())
val allowed = limiter.tryAcquire()
if (allowed) {
println("Запрос $id: ✅ Пропущен")
} else {
println("Запрос $id: ❌ Отклонен (429)")
}
}
}
jobs.joinAll()
}
Вывод будет примерно таким: первые 5 запросов пройдут (взяли все токены), а следующие 5 будут отклонены, пока токены не накопятся снова.
Интеграция с Ktor: Middleware для защиты эндпоинтов
Если вы пишете бэкенд на Ktor, rate limiting должен быть глобальным. Создадим простой плагин (или interceptor).
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
fun Application.installRateLimiter() {
val limiter = TokenBucketRateLimiter(maxTokens = 100, refillRate = 10) // 100 запросов, 10 в секунду
intercept(ApplicationCallPipeline.Call) {
if (!limiter.tryAcquire()) {
call.response.header(HttpHeaders.RetryAfter, "1")
call.respondText(
status = HttpStatusCode.TooManyRequests,
text = "{\"error\":\"Rate limit exceeded. Try again later.\"}",
contentType = ContentType.Application.Json
)
return@intercept finish()
}
}
}
fun Application.module() {
installRateLimiter()
routing {
get("/api/data") {
call.respondText("Hello, this is protected data!")
}
}
}
Этот код перехватывает каждый входящий запрос. Если лимит превышен — пользователь видит 429 и заголовок Retry-After. Клиенты (