🛡️ Защита от блокировок и Cloudflare

Бэкфилл всей истории dicechess.com — это устойчивая автоматизированная нагрузка с одного IP и одного JWT. Это и есть главный риск бана. Сайт защищён Cloudflare, а сверх него у самого приложения есть собственный rate-limit. Эта страница — про то, как мы остаёмся «незабаненными»: про транспорт (curl, а не fetch), про единый шлюз-лимитер (RateLimiter) и про дорого выученные операционные уроки.

Где это живёт

Транспорт — src/dicechess.ts, шлюз — src/ratelimit.ts, ручки — src/config.ts. Всё это часть сервиса dicechess-sync (см. 09 Pipeline - dicechess-sync). Транспорт и UA дословно переиспользованы из dicechess-observer.


1. Почему curl, а не fetch — TLS-фингерпринт Cloudflare

Cloudflare фингерпринтит TLS-клиента ещё до того, как доходит до HTTP. Дело не в заголовках и не в IP, а в самом TLS-рукопожатии (набор шифров, расширения, порядок — JA3/JA4-подобная сигнатура).

  • Node fetch (undici) даёт сигнатуру, которую Cloudflare помечает как бота, и возвращает 403 «managed challenge»даже с чистого residential IP. Никакой cf_clearance, никакой браузер это не лечит на стороне Node.
  • curl на OpenSSL ≥ 3.5 проходит. Поэтому базовый образ — node:26-trixie-slim (Debian trixie несёт OpenSSL 3.5). На bookworm (OpenSSL 3.0) тот же curl ловил 403.

Вывод: все запросы к игровому сайту идут через curl-subprocess (execFile('curl', ...)), а не через fetch. Запросы к нашей аналитике (POST /api/games на aurora, LAN) — обычный Node fetch: там Cloudflare нет, фингерпринт не важен.

// src/dicechess.ts — curlJson: браузероподобные заголовки + curl-транспорт
const args = [
  '-sS', '--max-time', '20',
  '-H', `User-Agent: ${UA}`,            // Chrome-like UA из config.ts
  '-H', 'Origin: https://dicechess.com',
  '-H', 'Referer: https://dicechess.com/',
  '-H', 'Accept: application/json',
  '-H', `Authorization: Bearer ${jwt}`,
  '-w', '\n%{http_code}',               // HTTP-код дописывается отдельной строкой в конец тела
  ...extra,
];

Форма ошибки задана здесь же: если status !== 200, бросается Error вида "<label> -> <NNN>: <первые 160 символов тела>", например "/api/player/history -> 429: ...". Эта стрелочная форма -> NNN — контракт между транспортом и классификатором троттла (см. §3).

Образ и секреты

node:26-trixie-slim тянет curl + ca-certificates через apt-get. Приватный клиент @rabestro/dicechess-raw-archive ставится из GitHub Packages, токен передаётся как BuildKit-секрет (--mount=type=secret), а не запекается в слой. JWT (DICECHESS_JWT) приходит из окружения (env_file: .env), а не из образа.


2. RateLimiter целиком — единый шлюз каждого запроса

Весь процесс держит один экземпляр RateLimiter (crawl-service.ts:34new RateLimiter(config.rateLimit)). Через него воронкой проходят все вызовы к сайту во всех проходах краулера. Это даёт глобальный single-flight: даже если перечисление и скачивание партий идут «параллельно» логически, к сайту в любой момент летит не больше одного запроса.

У лимитера пять механизмов, и они складываются.

2.1 Single-flight — хвостовая сериализация

schedule(fn) не запускает fn сразу. Он привязывает его к концу промис-цепочки #tail:

// src/ratelimit.ts
async schedule<T>(fn: () => Promise<T>): Promise<T> {
  const run = this.#tail.then(() => this.#runGated(fn));
  this.#tail = run.then(() => undefined, () => undefined); // цепочка живёт даже после провала
  return run;
}

Каждый новый вызов цепляется за хвост предыдущего, поэтому никогда нет параллелизма к сайту — выполнение строго последовательное. Обработчики () => undefined на обоих исходах гарантируют, что упавший запрос не рвёт цепочку: следующий в очереди всё равно стартует.

2.2 Spacing — минимальная пауза + джиттер

После каждого успешного запроса лимитер сдвигает #nextAllowedAt вперёд:

То есть между запросами всегда зазор minDelayMs плюс случайная добавка из [0, jitterMs). Джиттер делает темп менее роботизированным (не идеально-периодичным). Дефолты: minDelayMs = 3000, jitterMs = 1500 → фактический интервал 3–4.5 с.

2.3 Backoff — экспоненциальный cooldown

При сигнале троттла (см. §3) лимитер растит штрафной #cooldownMs:

  • первый сигнал: initialCooldownMs = 5000 (5 с);
  • каждый следующий подряд: × backoffFactor (по умолчанию × 2) → 5с → 10с → 20с → 40с …;
  • потолок: maxCooldownMs = 5 минут;
  • при первом успешном ответе #cooldownMs сбрасывается в 0 (распад) — восстановились, штраф снят.

Cooldown добавляется к #nextAllowedAt, то есть «садится» поверх обычного spacing и заставляет ждать либо ретрай, либо следующего вызывающего.

2.4 Retry-on-throttle — переждать и повторить тот же вызов

#runGated — это цикл for (attempt = 0; ; attempt++). На сигнале троттла, если ещё есть попытки (attempt < maxRetries), лимитер не бросает ошибку наверх, а ждёт выросший cooldown и повторяет тот же самый fn:

// src/ratelimit.ts — сердце #runGated (сокращённо)
const result = await fn();
this.#cooldownMs = 0;                                    // восстановились
this.#nextAllowedAt = now() + minDelayMs + random()*jitterMs;
return result;
// ... в catch:
if (limited) this.#cooldownMs = min(maxCooldownMs,
    this.#cooldownMs === 0 ? initialCooldownMs : this.#cooldownMs * backoffFactor);
this.#nextAllowedAt = now() + minDelayMs + random()*jitterMs + this.#cooldownMs;
if (limited && attempt < this.#maxRetries) continue;     // переждать и повторить
throw err;                                               // не-троттл или попытки кончились

Пока один вызов ретраит, вся очередь ждёт за ним — это ровно то, что нужно под троттлом: нет смысла лить новые запросы в 429. Дефолт maxRetries = 5. Не-троттл-ошибки (см. §3) и исчерпанный бюджет ретраев пробрасываются наверх немедленно.

2.5 Budget — бюджет на скользящее окно

Опционально: не больше maxPerWindow запросов за окно windowMs (по умолчанию 1 час). #awaitBudget хранит таймстампы в #window, выбрасывает протухшие (#prune) и, если окно заполнено, спит до момента, когда освободится самый старый слот. По умолчанию maxPerWindow = Infinity — бюджет выключен, пока не задан MAX_REQUESTS_PER_HOUR.

2.6 Поток #runGated

flowchart TD
    A["schedule(fn)<br/>цепляется за хвост #tail"] --> B["#runGated: attempt = 0"]
    B --> C["#awaitBudget()<br/>ждать, если окно полно"]
    C --> D{"now < nextAllowedAt?"}
    D -->|"да"| E["sleep(spacing + cooldown)"]
    D -->|"нет"| F
    E --> F["await fn()"]
    F -->|"успех"| G["cooldown = 0<br/>nextAllowedAt = now + minDelay + jitter"]
    G --> H(["вернуть результат"])
    F -->|"ошибка"| I{"isRateLimited(err)?"}
    I -->|"да — троттл"| J["cooldown = min(cap, cooldown==0 ? 5s : cooldown×2)"]
    I -->|"нет — обычная ошибка"| K["cooldown без изменений"]
    J --> L["nextAllowedAt = now + minDelay + jitter + cooldown"]
    K --> L
    L --> M{"троттл И attempt < maxRetries?"}
    M -->|"да"| N["attempt++"]
    N --> C
    M -->|"нет"| O(["throw err — наверх"])

Детерминированное тестирование

Часы (now), сон (sleep) и случайность (random) инжектируются через RateLimiterOptions. В тестах их подменяют, чтобы воспроизводить backoff и ретраи без реального ожидания.


3. Классификация троттла — что считать сигналом «притормози»

Функция defaultIsRateLimited(err) разбирает сообщение ошибки и решает, наращивать ли backoff:

// src/ratelimit.ts
export function defaultIsRateLimited(err: unknown): boolean {
  const msg = err instanceof Error ? err.message : String(err);
  const m = msg.match(/->\s*(\d{3})\b/);   // ловит "<label> -> NNN" из curlJson
  if (!m) return true;                      // нет HTTP-статуса = сетевой/curl-сбой → backoff
  const code = Number(m[1]);
  return code === 403 || code === 429 || code >= 500;
}

Сигналом троттла считается:

СлучайТрактовка
403Cloudflare managed challenge (фингерпринт/репутация) → backoff
429«Too many requests!» — собственный лимит сайта → backoff
5xxвременная серверная ошибка → backoff
любая ошибка без HTTP-статуса (сеть, таймаут curl, обрыв)backoff (консервативно — притормаживаем)
4xx, кроме 403/429 (напр. 404, 400)не троттл → ошибка пробрасывается сразу, без backoff

Классификатор можно переопределить через isRateLimited в опциях, но по умолчанию работает именно это правило, завязанное на стрелочную форму -> NNN из curlJson (§1).


4. Ручки окружения

Все настройки темпа читаются из env в src/config.ts. Дефолты подобраны в бою (см. §5).

ПеременнаяДефолтНазначение
REQUEST_MIN_DELAY_MS3000базовый зазор между запросами (minDelayMs)
REQUEST_JITTER_MS1500случайная добавка [0, jitterMs) к зазору
REQUEST_MAX_RETRIES5сколько раз переждать cooldown и повторить вызов на троттле
MAX_REQUESTS_PER_HOURInfinity (выкл.)бюджет запросов на скользящее часовое окно (maxPerWindow)
ENUMERATE_PAGE_SIZE500размер страницы POST /api/player/history (главный рычаг, см. §5)

Зашитые в код (не из env): initialCooldownMs = 5000, maxCooldownMs = 5 мин, backoffFactor = 2, windowMs = 3 600 000 (1 час).

ENUMERATE_PAGE_SIZE валидируется жёстко

positiveIntEnv падает при старте, если значение не положительное целое. Причина: условие выхода из цикла перечисления — rows.length < pageSize (enumerate.ts:173); при NaN или 0 оно никогда не сработает → бесконечный цикл запросов в самый rate-limited эндпоинт. Лучше упасть на старте, чем долбить сайт.

Полная таблица всех env сервиса (включая DICECHESS_JWT, ANALYTICS_*, SEED_IDS, BATCH_*) — в 09 Pipeline - dicechess-sync.


5. Ключевые операционные уроки

Это сердце страницы. Факты — из реальной эксплуатации бэкфилла; код отражает их лишь частично, в комментариях config.ts.

5.1 /api/player/history — узкое горлышко, и оно «липкое»

Асимметрия двух эндпоинтов

POST /api/player/history (перечисление истории игрока) — жёстко и липко лимитирован. Серия из ~18 быстрых страниц (≈26 req/мин) ловит 429 «Too many requests!», и троттл держится десятки минут — это не минутный всплеск. Лимит ставит сам сайт, не Cloudflare.

GET /api/game-move-history?gameId=... (скачивание партий — тот же эндпоинт, что у observer) — значительно свободнее: десятки тысяч фетчей подряд проходят с cooldown 0. Observer молотит его без проблем.

Практический вывод: бюджет и spacing настраивать под перечисление, а не под скачивание. Узкое место — каталогизация истории игроков, а не выкачка ходов.

5.2 Рычаг pageSize — главное лекарство

Размер страницы перечисления коллапсирует число запросов на полный свип игрока в 10–20 раз. Для игрока с 34k партий:

pageSizeЗапросов на полный свип
50~685
500 (дефолт)~68
1000 (проверено в бою)~35

Раз /api/player/history лимитирован, а cooldown липкий, уменьшение числа вызовов — куда эффективнее любого spacing. Поэтому дефолт ENUMERATE_PAGE_SIZE подняли 50 → 500, а для «китов» (игроков с огромной историей) env переопределяет до 1000. Подробнее про перечисление и свипы — в 04 Граф игроков и перечисление. Инкрементальный свип останавливается по курсору в любом случае, так что для него это просто один запрос с бóльшим ответом.

5.3 minDelayMs подняли 1500 → 3000

1500 мс оказалось слишком быстро — сайт отвечал 429 после серии страниц. Важный нюанс:

Часовой бюджет не ловит минутный всплеск

MAX_REQUESTS_PER_HOUR ограничивает темп на горизонте часа, но ничего не знает про всплеск внутри минуты. Если за минуту улетит 26 запросов, часовой бюджет это пропустит, а сайт — нет. Краткосрочный темп регулирует только spacing (minDelayMs). Поэтому подняли именно его, а ретраи лишь «переживают» редкие срабатывания поверх.

5.4 retry-on-throttle — почему его пришлось добавить

Изначально лимитер считал backoff-cooldown, но текущий запрос всё равно бросал ошибку наверх. Итог: один transient 429 посреди свипа убивал перечисление целого игрока, а вычисленный cooldown оставался мёртвым — никто его не пережидал и не повторял вызов.

Теперь (maxRetries = 5) на троттле лимитер сам ждёт нарастающий cooldown и повторяет тот же вызов до N раз. Не-троттл-ошибки и исчерпанный бюджет ретраев по-прежнему идут наверх. Именно это сделало бэкфилл устойчивым к одиночным 429.

sequenceDiagram
    participant C as Краулер
    participant L as RateLimiter
    participant S as dicechess.com
    C->>L: schedule(getPlayerHistory)
    L->>S: POST /api/player/history (page N)
    S-->>L: 429 Too many requests!
    Note over L: isRateLimited → true<br/>cooldown 0 → 5s<br/>attempt 0 < maxRetries
    L->>L: sleep(spacing + 5s)
    L->>S: POST /api/player/history (тот же page N)
    S-->>L: 200 OK
    Note over L: cooldown сброшен в 0
    L-->>C: PlayerHistoryResponse

5.5 Сырой кэш = пересчёт без перескрейпа

Правила нормализации / FEN / проверки движком дорабатываются. Сырьё (raw_game_data локально в SQLite-каталоге + bronze-архив на dexus, см. Raw-архив — bronze-слой) позволяет переконвертировать всю историю локально, ни разу не обращаясь к защищённому сайту. Сам dicechess-sync при этом всегда делает только POST /api/games — у него нет PUT. Путь переконвертации — отдельный конвертер: он реплеит кэшированное сырьё и перезаливает результат через replace-эндпоинт аналитики PUT /api/games/{id} (это ручка backend dicechess-analytics, контракт — в 07 Контракт ingest и валидация движком), а не что-то, что делает краулер сегодня. Лучшая защита от блокировки — не делать лишних запросов вовсе. Детали реплея и идемпотентности — в 05 Конвейер партий и состояния.


6. Где это в инфраструктуре

Краулер и observers крутятся на rpi4 (192.168.10.9) — у этой машины residential egress-IP, через который и идёт трафик к Cloudflare. Аналитика, куда заливаются партии (POST /api/games), живёт на aurora (192.168.10.3:8020) в LAN — туда curl-обход не нужен, там обычный fetch. Контракт ingest и реплей-гейт движком — на 07 Контракт ingest и валидация движком.


См. также