JavaRush /Курсы /Swift SELF /Rate limiting — «не DDOS’им API сами себя»

Rate limiting — «не DDOS’им API сами себя»

Swift SELF
67 уровень , 2 лекция
Открыта

1. Rate limiting: зачем он нужен

Если вам кажется, что rate limiting — это забота сервера, вы мыслите как оптимист. А сетевой код должен мыслить как человек, который однажды нажал «Запустить» и случайно отправил 300 запросов за секунду, потому что у него был цикл for и немного уверенности в себе.

Rate limiting на клиенте нужен не для красоты:

  • снижает вероятность получить 429 Too Many Requests;
  • уменьшает нагрузку на API;
  • делает поведение приложения более воспитанным и предсказуемым.

Представим ситуацию в нашем учебном CLI LibraryCLI: есть команда fetch (или похожая), которая дергает внешний API за данными книги. Пользователь может вызвать её несколько раз подряд, может написать скрипт, а может случайно запустить команду в цикле. Без ограничений вы превращаетесь в маленький домашний DDoS — только без злого умысла (а это, как ни странно, один из самых частых сценариев).

Rate limiting — это правило вида: «не чаще, чем раз в N секунд». Оно простое, но полезное, как привычка сохранять файл перед тем, как IDE внезапно решит обновиться.

Ментальная модель: «шлагбаум» перед сетью

Rate limiter удобно представлять как шлагбаум на выезде из вашего приложения в интернет. Машины (запросы) могут ехать, но не могут ехать вплотную бампер-в-бампер: между ними должен быть минимальный интервал. Если следующая машина подъехала слишком рано — она просто подождёт.

sequenceDiagram
    participant App as LibraryCLI
    participant Lim as RateLimiter
    participant Net as HTTPClient (реальная сеть)

    App->>Lim: send(request)
    Lim->>Lim: проверка lastRequestAt
    alt прошло достаточно времени
        Lim->>Net: отправить сразу
    else слишком рано
        Lim->>Lim: Task.sleep(remaining)
        Lim->>Net: отправить после ожидания
    end

Важно: limiter не «ускоряет интернет». Он просто дисциплинирует нас. Это как будильник: он не делает утро приятным, он делает утро неизбежным.

Не путаем: rate limiting, backoff и cache

Очень типичная путаница: «А разве backoff уже не про ожидание? Зачем ещё limiter?» Похоже, но смысл разный. Удобно сравнить три политики одной таблицей.

Политика Когда применяется Что делает Зачем
Cache До сети Иногда вообще не идём в сеть Уменьшает число запросов
Rate limiting Перед каждым реальным запросом Ограничивает частоту запросов Не «спамим» API, меньше 429
Backoff После ошибки и перед повтором Делает паузу (растущую) между попытками Даёт шанс «само пройдёт», не усиливает аварию

Cache — это «не делай лишнего». Rate limiting — «делай, но не слишком часто». Backoff — «если всё сломалось, не бей в дверь лбом каждые 0.001 секунды».

Ещё тонкость: rate limiting обычно действует «даже если всё хорошо». Backoff — реакция на проблему. То есть limiter — это дисциплина, backoff — осторожность после удара о стену.

Почему limiter хранит состояние

Если бы limiter был «чистой функцией», он бы не знал, что было раньше. Но ему нужно помнить, когда мы отправляли последний запрос. Значит, он хранит состояние: например lastRequestAt.

И вот тут начинается взрослая жизнь: любой компонент с состоянием становится источником потенциальных багов. В этой версии мы фиксируем важное ограничение: один экземпляр limiter’а предполагает последовательные вызовы (single-writer). То есть мы не проектируем его так, чтобы два параллельных Task одновременно дергали один и тот же limiter и он при этом был корректен.

Почему? Потому что если два параллельных вызова одновременно посмотрят на lastRequestAt, оба увидят «можно», и оба уйдут в сеть — а вы такие: «Ну я же ограничивал!» Ограничивали, но без изоляции общего состояния это превращается в «надеялся на лучшее».

Мы пока не обсуждаем механизмы изоляции общего состояния (это отдельная большая тема), поэтому дальше придерживаемся простого правила: если компонент хранит изменяемое состояние, используем его последовательно.

2. Реализуем простой RateLimiter

Самая понятная реализация limiter’а строится вокруг двух вещей:

  1. задаём минимальный интервал между запросами, например 0.2 секунды (то есть не чаще 5 запросов в секунду);
  2. храним момент времени, когда был отправлен предыдущий запрос.

Если новый запрос приходит раньше, чем через minInterval, мы ждём.

Ключевой момент для async-мира: «ждать» — не значит блокировать поток. Никаких sleep(1) из C. Мы приостанавливаем задачу через Task.sleep, чтобы другие задачи могли выполняться, а приложение не превращалось в «я жду — значит весь мир тоже ждёт».

Базовый RateLimiter

import Foundation

struct RateLimiter {
    let minInterval: TimeInterval
    private var lastRequestAt: Date?

    mutating func waitIfNeeded() async throws {
        let now = Date()

        if let last = lastRequestAt {
            let elapsed = now.timeIntervalSince(last)
            let remaining = minInterval - elapsed
            if remaining > 0 {
                let ns = UInt64((remaining * 1_000_000_000).rounded())
                try await Task.sleep(nanoseconds: ns)
            }
        }

        try Task.checkCancellation()
        lastRequestAt = Date()
    }
}

Здесь несколько вещей стоит «пощупать глазами».

Мы берём let now = Date(), вычисляем сколько времени прошло с момента прошлого запроса, и если не прошло достаточно — ждём остаток. После ожидания мы проверяем отмену через Task.checkCancellation(). Это важно: если задачу уже отменили (например, пользователь прервал команду), мы не должны продолжать, будто ничего не случилось.

И обратите внимание: мы обновляем lastRequestAt после ожидания. Это логика «время последнего разрешённого запроса», а не «время последней попытки».

Утилита: секунды → наносекунды

Иногда удобнее вынести конвертацию, чтобы меньше рисковать «лишним ноликом».

import Foundation

func toNanoseconds(_ seconds: TimeInterval) -> UInt64 {
    let safe = max(0, seconds)
    return UInt64((safe * 1_000_000_000).rounded())
}

Это не обязательный код, но он снижает шанс ошибки «я думал, что это миллисекунды».

3. Встраиваем limiter в HTTPClient

Теперь самое важное: куда это прикрутить, чтобы limiter реально работал, а не лежал в папке «утилиты» как талисман.

Мы уже работали с идеей HTTPClient как транспорта: у него есть метод send(_:), который возвращает (Data, HTTPURLResponse) или бросает ошибку. Значит, limiter логично поставить вокруг транспорта: сделать обёртку, которая перед отправкой ждёт, если нужно.

Контракт HTTPClient

import Foundation

protocol HTTPClient {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

Обёртка RateLimitedHTTPClient

import Foundation

final class RateLimitedHTTPClient: HTTPClient {
    private var limiter: RateLimiter
    private let base: HTTPClient

    init(base: HTTPClient, minInterval: TimeInterval) {
        self.base = base
        self.limiter = RateLimiter(minInterval: minInterval)
    }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        try await limiter.waitIfNeeded()
        return try await base.send(request)
    }
}

limiter здесь — var, потому что он меняется. И это ещё раз подчёркивает: компонент stateful. А раз он stateful, мы снова вспоминаем ограничение: один экземпляр этого клиента — последовательные вызовы. Если вы начнёте звать send параллельно из двух задач, вы можете получить «двойной проезд под шлагбаумом».

Правильный порядок: cache раньше limiter’а

Это один из самых «коварных» моментов, потому что код может быть «логически правильным», но UX будет странным.

Если limiter стоит раньше cache, то даже когда данные уже есть в памяти, вы всё равно будете ждать. Это похоже на ситуацию, когда вы стоите в очереди в кафе, чтобы… посмотреть на меню, которое у вас уже открыто в телефоне.

Правильная идея: сначала попробуй cache, и только если cache не сработал — применяй limiter и иди в сеть.

Композиционно это означает: cache должен быть внешней обёрткой, а limiter — внутри, ближе к реальной сети.

flowchart LR
    A[ApiClient] --> B[CachingHTTPClient]
    B --> C[RateLimitedHTTPClient]
    C --> D[URLSessionHTTPClient]

То есть CachingHTTPClient на cache-hit вообще не вызывает base.send, а значит limiter не участвует.

Псевдо-композиция (без деталей реализации кэша, потому что она была в предыдущей лекции дня):

// Схема сборки пайплайна: cache -> limiter -> base
func makeHTTPPipeline(base: HTTPClient) -> HTTPClient {
    let limited = RateLimitedHTTPClient(base: base, minInterval: 0.2)
    let cached = CachingHTTPClient(base: limited, ttl: 10) // ttl в секундах
    return cached
}

Здесь важна мысль, а не конкретные типы: порядок политик — часть дизайна.

Мини-демо без реальной сети

Сетевой код коварен тем, что вы можете не заметить, что limiter работает. Поэтому иногда полезно сделать «фейковый HTTPClient», который просто печатает время, когда его вызвали.

import Foundation

struct PrintHTTPClient: HTTPClient {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        print("SEND at \(Date())") // например: 2026-01-16 12:00:00 +0000
        let response = HTTPURLResponse(url: request.url!, statusCode: 200,
                                       httpVersion: nil, headerFields: nil)!
        return (Data(), response)
    }
}
import Foundation

func demoRateLimiting() async throws {
    let base = PrintHTTPClient()
    let client = RateLimitedHTTPClient(base: base, minInterval: 1.0)

    let url = URL(string: "https://example.com")!
    let req = URLRequest(url: url)

    _ = try await client.send(req)
    _ = try await client.send(req)
    _ = try await client.send(req)
}

Если всё работает, вы увидите, что между строками печати примерно секунда. Не миллисекунда, не «кажется», а реальная пауза. И это хороший знак: limiter — не «оптимизация», limiter — дисциплина.

Где хранить эту политику

Когда вы проектируете слой сети, всегда возникает вопрос: «где именно хранить эти политики?» Если размазать их по коду, получится «паутина if-ов», которую никто не любит, кроме багов (баги любят паутину, там уютно).

Хорошее место для limiter’а — там же, где живёт отправка запроса, то есть рядом с транспортом. Тогда ApiClient может быть сфокусирован на интерпретации ответа, декодировании DTO и маппинге ошибок, а limiter будет гарантировать частоту для любого запроса, который реально уходит в сеть.

Ещё один плюс такого решения: если у вас есть retry-логика выше по стеку, то каждая retry-попытка, которая действительно пойдёт в сеть, автоматически будет «пропущена через limiter». И вы не получите ситуацию «у меня есть limiter, но ретраи его обходят».

4. Ограничение: только последовательные вызовы

Наша реализация — минимальная и учебная. Она специально рассчитана на последовательные вызовы, потому что мы пока не вводили механизмы безопасной изоляции общего изменяемого состояния.

Если коротко, проблема выглядит так: lastRequestAt — общий изменяемый кусочек памяти. Если два параллельных вызова send пришли одновременно, они могут оба решить, что ждать не надо, и оба отправят запрос. В лучшем случае limiter «не сработает». В худшем — вы получите трудноуловимые гонки, когда иногда ждёт, иногда нет.

Поэтому правило здесь спокойное и без героизма: один экземпляр limiter’а / rate-limited клиента используем последовательно. Если вам нужно параллелить сетевые запросы — это отдельный дизайн-вопрос, который мы сейчас не решаем.

5. Типичные ошибки при добавлении rate limiting

Ошибка №1: путать rate limiting и backoff, и ожидать, что limiter «лечит ошибки».
Limiter не лечит ни 500, ни таймаут, ни плохой Wi‑Fi. Он всего лишь регулирует частоту запросов. Если сеть упала, вы будете красиво и дисциплинированно ошибаться, но всё равно ошибаться. Лечить временные сбои должен retry/backoff, а limiter — снижать шанс «самому создать проблему частотой».

Ошибка №2: поставить limiter «до кэша» и удивляться, что на cache-hit всё равно есть задержка.
Если limiter стоит внешней обёрткой, он срабатывает даже тогда, когда сеть не нужна. Это даёт странный UX: «почему команда думает секунду, если данные уже в памяти?» Правильнее строить пайплайн так, чтобы cache перехватывал запрос раньше, а limiter включался только на cache-miss.

Ошибка №3: создать несколько экземпляров limiter’а и случайно отключить ограничение.
Rate limiting работает, только если состояние общее для всех запросов, которые вы хотите ограничить. Если вы создаёте новый RateLimitedHTTPClient на каждый запрос, lastRequestAt каждый раз будет nil, и limiter будет пропускать всё сразу. Это одна из самых обидных ошибок, потому что код «выглядит правильно», но логика «забывает прошлое».

Ошибка №4: не уважать отмену и продолжать ждать/слать запрос, хотя он уже не нужен.
Если вы делаете Task.sleep, но не проверяете отмену, вы можете потратить время на ожидание и даже отправить запрос, хотя пользователь уже отменил операцию. В async-коде отмена — это часть нормального управления потоком, а не «редкий случай».

Ошибка №5: пытаться использовать один и тот же limiter из параллельных задач без договорённостей.
Наша версия limiter’а предполагает последовательные вызовы. Если вы начнёте дергать один экземпляр одновременно из нескольких Task, вы получите гонки и странное поведение «то ждёт, то не ждёт». Пока мы не обсуждали изоляцию общего состояния, это нужно принять как ограничение дизайна, а не как «случайную мелочь».

1
Задача
Swift SELF, 67 уровень, 2 лекция
Недоступна
Наносекунды таймера
Наносекунды таймера
1
Задача
Swift SELF, 67 уровень, 2 лекция
Недоступна
Вежливый ограничитель
Вежливый ограничитель
1
Задача
Swift SELF, 67 уровень, 2 лекция
Недоступна
Клиент с лимитом
Клиент с лимитом
1
Задача
Swift SELF, 67 уровень, 2 лекция
Недоступна
Кэш поверх лимита
Кэш поверх лимита
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ