Rate Limiting и Throttling в Kotlin: Как защитить API от перегрузок

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Когда ваш 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. Клиенты (

Блоги

Рекомендаций по книгам