Лимиты, устойчивость и защита от злоупотреблений

Урок о том, как лимиты и паттерны устойчивости одновременно защищают API от злоупотреблений и от собственных аварий.

Доступность — третья сторона триады информационной безопасности (наряду с конфиденциальностью и целостностью). Атака на доступность не крадёт данные, а лишает легитимных пользователей сервиса; защита от неё — такая же задача безопасности, как защита от инъекций.

API без ограничений уязвим сразу с двух сторон. Снаружи его можно завалить запросами (перебор паролей, выкачивание данных, DoS). Изнутри один медленный или упавший сервис способен утянуть за собой всю систему — это каскадный отказ. Лимиты, таймауты, ретраи и circuit breaker — инструменты, которые делают систему устойчивой к обоим сценариям.

Зачем это знать защитнику

Многие атаки — это не «магический эксплойт», а злоупотребление штатной функциональностью на скорости и в объёме: тысячи попыток входа (credential stuffing), массовый перебор объектов через тот самый id из первого урока, дорогие запросы, исчерпывающие ресурсы. Грамотные лимиты переводят такие атаки из «дёшево и эффективно» в «дорого и заметно».

Rate limiting: ограничение частоты

Идея проста: ограничить, сколько запросов клиент может сделать за интервал времени. Это сбивает перебор, удорожает автоматизированные злоупотребления и сглаживает всплески нагрузки. Распространённый алгоритм — token bucket (ведро токенов): на каждый запрос тратится токен, токены пополняются с фиксированной скоростью; кончились — запрос отклоняется.

import time

class TokenBucket:
    def __init__(self, capacity, refill_per_sec):
        self.capacity = capacity          # ёмкость ведра
        self.tokens = capacity
        self.refill = refill_per_sec      # скорость пополнения
        self.last = time.monotonic()

    def allow(self):
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

bucket = TokenBucket(capacity=5, refill_per_sec=1)
results = [bucket.allow() for _ in range(8)]
print(results)

Вывод:

[True, True, True, True, True, False, False, False]

Первые пять запросов проходят (ведро было полным), дальше — отказ, пока не накопятся токены. На отклонённый запрос API отвечает кодом 429 Too Many Requests и заголовком Retry-After. Лимиты обычно ставят на нескольких уровнях: по ключу/пользователю, по IP и глобально на эндпоинт. На особо чувствительные операции (логин, сброс пароля) лимиты делают строже.

Таймауты: не ждать вечно

Каждый вызов соседнего сервиса должен иметь таймаут. Без него медленный ответ зависшего сервиса задерживает ваш поток, потоки копятся, пул соединений исчерпывается — и ваш сервис тоже «ложится», хотя сам исправен. Таймаут превращает чужую медлительность в быстрый управляемый отказ:

вызов сервиса оплат с таймаутом 2с
  если ответ за 2с      -> обрабатываем
  если дольше 2с        -> прерываем, отдаём управляемую ошибку
                           (не держим поток в ожидании)

Ретраи — осторожно

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

Circuit breaker: предохранитель

Если сосед стабильно не отвечает, продолжать к нему стучаться бессмысленно и вредно. Circuit breaker работает как электрический предохранитель и имеет три состояния:

CLOSED    — запросы идут как обычно; считаем ошибки.
  └─ слишком много ошибок подряд ──> OPEN
OPEN      — запросы к сервису сразу отклоняются (fail-fast),
            сосед получает передышку, мы не копим зависшие вызовы.
  └─ выждали таймаут ──> HALF-OPEN
HALF-OPEN — пропускаем несколько пробных запросов.
  └─ успех  ──> CLOSED   (восстановился)
  └─ ошибка ──> OPEN     (ещё не готов)

В состоянии OPEN сервис не тратит ресурсы на заведомо провальные вызовы и может отдать пользователю запасной ответ (graceful degradation) — например, кэш или вежливую заглушку вместо зависания.

Как это работает под капотом: каскадные отказы

Каскад начинается с малого: сервис D тормозит. Вызовы к нему из C не завершаются и держат потоки. Потоки C заканчиваются — C перестаёт отвечать B. Дальше падает B, за ним A — и пользователь видит полностью неработающую систему из-за одного медленного звена в глубине. Это и есть каскадный отказ. Связка таймаут + circuit breaker + изоляция (ограничение ресурсов на каждую зависимость, bulkhead) локализует проблему: сбой D остаётся сбоем D, а не всей системы. Цель — частичная деградация вместо полного коллапса.

Как защититься

  • Ставьте rate limiting на нескольких уровнях (пользователь, IP, глобально); на логин и сброс пароля — строже. Отвечайте 429 + Retry-After.
  • Назначайте таймаут каждому внешнему вызову; «ждать бесконечно» по умолчанию недопустимо.
  • Ретраи — только для идемпотентных операций, с лимитом попыток и экспоненциальной задержкой с jitter.
  • Оборачивайте нестабильные зависимости в circuit breaker и предусматривайте запасной ответ (degradation).
  • Изолируйте ресурсы по зависимостям (bulkhead), чтобы один сбой не выел весь пул.
  • Мониторьте долю ошибок и срабатывания предохранителей — резкий рост 429 или открытие breaker'ов сигналит и об атаке, и об аварии.

Нагрузочное тестирование проводите только против своих систем и в согласованных окнах: умышленная перегрузка чужого сервиса — это DoS-атака и нарушение закона (в РФ — ст. 273/274 УК РФ).

Итоги

  • Доступность — часть безопасности; защита от злоупотреблений и от каскадов решает её.
  • Rate limiting удорожает перебор и DoS, сглаживает всплески; отвечайте 429 с Retry-After.
  • Таймауты и аккуратные ретраи (идемпотентность, backoff + jitter) не дают чужой медлительности заразить вас.
  • Circuit breaker и изоляция ресурсов локализуют сбой и обеспечивают частичную деградацию вместо полного коллапса.
Проверьте себя
1. Зачем оборачивать вызов нестабильного сервиса в circuit breaker?
AЧтобы зашифровать трафик между сервисами
BЧтобы при стабильных ошибках перестать слать заведомо провальные запросы (fail-fast) и дать соседу передышку
CЧтобы автоматически повторять запрос бесконечно
DЧтобы ускорить успешные ответы соседа
2. Какое условие делает повтор (retry) запроса безопасным?
AОперация должна быть идемпотентной, попытки ограничены, задержка растёт экспоненциально с jitter
BПовторять нужно как можно чаще и без задержек
CРетраи допустимы только для операций, списывающих деньги
DДостаточно повторять бесконечно, пока не получится