🛡️ Защита от блокировок и 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:34 — new 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;
}Сигналом троттла считается:
| Случай | Трактовка |
|---|---|
403 | Cloudflare 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_MS | 3000 | базовый зазор между запросами (minDelayMs) |
REQUEST_JITTER_MS | 1500 | случайная добавка [0, jitterMs) к зазору |
REQUEST_MAX_RETRIES | 5 | сколько раз переждать cooldown и повторить вызов на троттле |
MAX_REQUESTS_PER_HOUR | Infinity (выкл.) | бюджет запросов на скользящее часовое окно (maxPerWindow) |
ENUMERATE_PAGE_SIZE | 500 | размер страницы 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 и валидация движком.
См. также
- 04 Граф игроков и перечисление — где
pageSizeрешает, и почему перечисление узкое. - 09 Pipeline - dicechess-sync — полная таблица env и демон-цикл краулера.
- 05 Конвейер партий и состояния — сырой кэш, реплей, идемпотентность.
- 03 Синхронизация — обзор и архитектура — общая картина добычи.
- Live-наблюдатель (dicechess-observer) — live-наблюдатель, который свободно молотит
game-move-history. - Где лежит JWT после логина — откуда берётся
DICECHESS_JWT.