Лимиты, устойчивость и защита от злоупотреблений
Урок о том, как лимиты и паттерны устойчивости одновременно защищают 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 и изоляция ресурсов локализуют сбой и обеспечивают частичную деградацию вместо полного коллапса.