ADR-0008: Централизованный rate limiter на Redis

Status: accepted Date: 2026-04-17 Deciders: команда проекта

Контекст

Внешние поставщики (начиная с ETM) имеют строгие rate limits:

  • ETM: 1 req/s на /goods/*, 1 req/2 мин на /user/login.
  • Другие поставщики — свои.

Превышение → блокировка IP. Неприемлемо.

В будущем — несколько реплик ingestor’а (масштабирование, HA), необходим shared rate state.

Решение

Централизованный rate limiter на Redis, token bucket. Два класса bucket’ов на credential: AuthBucket и DataBucket[endpoint_class].

  • Отдельный модуль platform/rate-limiter.
  • Конфиг per-supplier, per-credential, per-endpoint.
  • Каждый connector получает токен перед исходящим запросом.
  • При отсутствии токена — поведение зависит от класса bucket’а (см. ниже).

Два класса bucket’ов

КлассПример (ETM)Поведение при исчерпанииПочему
AuthBucketlogin — 1 req / 120sБлокирующее ожидание, не failПроигрыш auth rate приводит к бану IP; запрос должен ждать. Connector не переключает схему auth при rate fail.
DataBucket[endpoint_class]price, stock, goods, async_poll — 1 req / 1s у ETMEnrichmentJobSkipped + reschedule с backoffData-запрос можно пропустить — observation станет stale, Pricing graceful degrade. Нет смысла блокировать worker.

AuthBucket никогда не делит бюджет с DataBucket. Даже при полном исчерпании data-budget — auth запрос проходит (нужен, чтобы восстановить session).

Ключи bucket’ов

  • AuthBucket: rl:auth:{supplier_id}:{credential_fingerprint}.
  • DataBucket: rl:data:{supplier_id}:{credential_id}:{endpoint_class}.

Fingerprint используется для auth, потому что члены одной SupplierCredentialGroup делят фактические login-попытки (один логин/пароль — один bucket). credential_id используется для data, потому что token rate limit у поставщика чаще считается на активную сессию, не на пару login/pass.

Конфиг example (ETM)

rate_limit:
  auth:
    login:
      rate: 1
      period: 120s
      mode: wait            # блокирующее ожидание
  data:
    price:
      rate: 1
      period: 1s
      mode: skip_and_reschedule
      backoff: { initial: 500ms, max: 30s, factor: 2 }
    goods:
      rate: 1
      period: 1s
      mode: skip_and_reschedule
    remains:
      rate: 1
      period: 1s
      mode: skip_and_reschedule
    async_poll:
      rate: 2
      period: 1s
      mode: skip_and_reschedule

Отдельный EgressIPBucket (опционально)

Для поставщиков, проверяющих IP-клиента (ETM требует письмо для foreign hosting), добавляется информационный EgressIPBucket{ip} — не ограничивает, только собирает метрики, чтобы заметить rotate IP / неверный deploy.

Последствия

Плюсы

  • Единая точка контроля.
  • Корректно работает на нескольких репликах.
  • Конфиг в одном месте.
  • Централизованные метрики: “запросы к ETM в минуту”, “rate limit hits”.

Минусы

  • Redis становится критичным компонентом для ingestion (mitigate: при недоступности Redis — fail-safe с локальным limiter, но более консервативным).
  • Дополнительный network hop на каждый запрос (mitigate: client-side prefetch токенов пачками).

Нейтральные последствия

  • Можно расширить до API rate limiting для нашего собственного API.

Рассмотренные альтернативы

Локальный rate limiter в каждой реплике

Ломается при > 1 реплики.

DB-based (PostgreSQL as limit store)

Слишком медленно для per-request check.

Envoy / API gateway rate limit

Overkill на текущей фазе, не даёт нужного уровня контроля per-endpoint.

Ссылки

  • Principles: P7.
  • ../integration-patterns.md
  • ADR-0024 (supplier connector contract) — двухуровневый rate budget как часть обязательного контракта connector.