From c0e45b48d367301db8392ff1a03ea4c9dc74255f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 00:28:55 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(smoke):=20=D0=BE=D1=82=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D1=8C=20=C2=AB=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B1=D1=8B=D0=BB=D0=BE=C2=BB=20=D0=BE?= =?UTF-8?q?=D1=82=20=C2=AB=D0=BA=D0=BE=D0=B4=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D1=82=C2=BB=20=D0=B2=20=D1=81=D0=BC=D0=BE=D1=83=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогон perimeter-smoke-mera на голове main (a659b187) покраснел на двух последних проверках с кодом `000`. `000` у curl — это не «пришёл неверный код», а «ответа не было вовсе»: периметр был цел, те же пути вручную отдавали 503 и 405 от приложения (server: uvicorn, не заглушка Caddy). Замер причины (внешний IP, /trade-in/api/v1/me): серия без пауз — 5 обрывов из 12, с паузой 0.5 c — 6 из 12, с паузой 2 c — 0 из 8; с самого хоста прода — 0 из 10. Приложение отвечает, частую серию запросов с одного адреса отбивает вход. Смоук шлёт 44 запроса подряд и попадает под тот же эффект, поэтому падают последние проверки списка. Что сделано (ожидания и список путей НЕ тронуты): - один общий curl_try, через который идут все запросы смоука. Повтор только при «ответа не было» — признак берётся у самого curl (ненулевой код возврата), ответ с «не тем» кодом для curl успешен и не повторяется никогда, иначе ретрай маскировал бы настоящий регресс; - отдельная формулировка FAIL (ответа нет) + итоговая строка «БЕЗ ОТВЕТА: N проверок» — чтобы читатель красного лога не искал регресс периметра там, где измерения не было; - пауза 2 c между проверками. Наименьшая величина, у которой есть замер: 0.5 c измеренно не помогает, промежуточные значения не мерил никто; - timeout-minutes воркфлоу 5 → 10: обычный прогон 89 c → 160 c (замер), а неотвечающая проверка стоит до 3×15 c таймаута плюс паузы, и job убивали бы до печати FAIL-строк. Побочно тем же сторожем закрыты места, где обрыв врал диагнозом: в check_redirect_location он читался как «Location не тот», а обрыв на загрузке лэндинга — как «сам лэндинг сломан». Проверка правки: прогон до (89 c, 43/43 PASS) и после (160 c, 43/43 PASS); подставной curl, роняющий каждый нечётный запрос, — 44 повтора, итог зелёный; фальсификация с подменённым ожиданием (/me → 200) и неверным URL (/oferta-net-takogo) — обе строки красные, повторов ноль, выход 1. --- .forgejo/workflows/perimeter-smoke.yml | 9 +- scripts/smoke-mera-perimeter.sh | 170 +++++++++++++++++++++---- 2 files changed, 156 insertions(+), 23 deletions(-) diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml index 1afdf167..17ac34bf 100644 --- a/.forgejo/workflows/perimeter-smoke.yml +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -35,7 +35,14 @@ concurrency: jobs: smoke: runs-on: ubuntu-latest - timeout-minutes: 5 + # 11.09.2026: было 5 минут — теперь мало. В скрипте появились пауза между + # проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный + # прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому + # что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы + # (~53 c против обычных ~2 c). Трёх таких хватило бы, чтобы упереться в + # 5 минут и job был бы убит ДО того, как напечатает строки FAIL и итог — + # то есть ровно в тот момент, когда лог нужнее всего. + timeout-minutes: 10 steps: - name: Checkout repo diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 867b5f45..7a32bcaa 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -38,12 +38,93 @@ BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}" BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}" fail=0 +noresp=0 + +# --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) -------------------------------- +# +# ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних +# проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код», +# а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же +# пути, запрошенные вручную, отдавали 503 и 405 от приложения. +# +# ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me): +# серия без пауз — 5 обрывов из 12 +# серия с паузой 0.5 c — 6 обрывов из 12 +# серия с паузой 2 c — 0 обрывов из 8 +# с самого хоста прода — 0 обрывов из 10 (все 401) +# То есть приложение отвечает, а частую серию запросов с одного внешнего +# адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса +# подряд без пауз и попадает ровно под этот эффект — и падают именно +# ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран. +# +# ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ: +# 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом — +# это результат проверки, он не повторяется никогда: иначе ретрай +# маскировал бы настоящий регресс периметра, ради которого всё написано. +# 2. Пауза между проверками, чтобы серия не выглядела флудом. +# +# ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть +# замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без +# пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и +# взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон +# целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной +# машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят +# timeout-minutes — у неотвечающей проверки цена совсем другая (до 3×15 c +# таймаута плюс паузы), и прежних 5 минут на худший случай не хватало. +# Обе величины переопределяются из окружения — для отладки локально +# (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп). +SMOKE_PAUSE="${SMOKE_PAUSE:-2}" +SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}" + +# curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после. +# +# Признак «ответа не было» берём у самого curl — ненулевой код возврата (28 +# таймаут, 35/52/56 обрыв соединения и TLS, 6 DNS). Он строго эквивалентен +# `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые +# http_code вообще не запрашивают: до этой правки обрыв в +# check_redirect_location читался как «Location не тот», а обрыв при загрузке +# лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все +# места сразу, а не только те две проверки, что покраснели. +# +# Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не +# будет; проверка отработает ровно так же, как до правки. +curl_try() { + local attempt=1 rc out + while :; do + out=$(curl "$@" 2>/dev/null) + rc=$? + { [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break + # Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше). + echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2 + sleep "$((attempt * 2))" + attempt=$((attempt + 1)) + done + [ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE" + printf '%s' "$out" + return "$rc" +} + +# no_response: отдельная формулировка для «ответа не было». +# +# Это FAIL (прогон обязан покраснеть — мы действительно не знаем, цел ли +# периметр), но формулировка другая специально: читатель красного лога не +# должен искать регресс периметра там, где измерения не было вовсе. +no_response() { + local desc="$1" url="$2" + echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток;" \ + "это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)" + fail=1 + noresp=$((noresp + 1)) +} check() { local desc="$1" url="$2" expected="$3" - local code - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) - if [ "$code" = "$expected" ]; then + local code rc + code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + elif [ "$code" = "$expected" ]; then echo "PASS: $desc ($url -> $code)" else echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" @@ -63,8 +144,13 @@ check() { check_any() { local desc="$1" url="$2" shift 2 - local code expected="$*" - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) + local code rc expected="$*" + code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + return + fi for want in "$@"; do if [ "$code" = "$want" ]; then echo "PASS: $desc ($url -> $code)" @@ -77,11 +163,16 @@ check_any() { check_post() { local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}" - local out code head_and_body + local out code head_and_body rc # -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ # приложения от заглушки Caddy (см. $reject у вызова payments/notify). - out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \ - -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null) + out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \ + -X POST -H 'Content-Type: application/json' -d "$body" "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + return + fi code=${out##*$'\n'} head_and_body=${out%$'\n'*} if [ "$code" != "$expected" ]; then @@ -111,8 +202,17 @@ check_post() { # Ровно это и есть предмет проверки — что query дожила до цели. check_redirect_location() { local desc="$1" url="$2" expected_suffix="$3" - local location - location=$(curl -s -o /dev/null -D - --max-time 15 "$url" 2>/dev/null \ + local location headers rc + # Заголовки сначала забираем целиком, и только потом разбираем: при обрыве + # соединения grep по пустому выводу дал бы «Location: » — красную + # строку про подмену цели редиректа там, где ответа не было вовсе. + headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + return + fi + location=$(printf '%s' "$headers" \ | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//') case "$location" in "$expected_suffix"|*"$expected_suffix") @@ -133,8 +233,13 @@ check_redirect_location() { # смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт. check_content_type() { local desc="$1" url="$2" expected_prefix="$3" - local out code ctype - out=$(curl -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) + local out code ctype rc + out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + return + fi code=${out##*$' '} ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d ' @@ -240,8 +345,13 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти # (замер на проде 02.09.2026: 9 байт). check_caddy_404() { local desc="$1" url="$2" - local out code size - out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null) + local out code size rc + out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url") + rc=$? + if [ "$rc" -ne 0 ]; then + no_response "$desc" "$url" + return + fi code=${out%% *} size=${out##* } if [ "$code" = "404" ] && [ "$size" = "0" ]; then @@ -262,15 +372,23 @@ check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \ # Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться # живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы # успешной, а публичный сайт молча остался бы без JS. -layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \ - | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1) -if [ -z "$layout_chunk" ]; then - # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о - # чём — поэтому это FAIL, а не «пропустим». - echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)" - fail=1 +layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/") +layout_rc=$? +if [ "$layout_rc" -ne 0 ]; then + # Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался + # как «сам лэндинг сломан» — диагноз, которого никто не измерял. + no_response "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" else - check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200 + layout_chunk=$(printf '%s' "$layout_html" \ + | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1) + if [ -z "$layout_chunk" ]; then + # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о + # чём — поэтому это FAIL, а не «пропустим». + echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)" + fail=1 + else + check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200 + fi fi # 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* @@ -411,6 +529,14 @@ check "trade-in payments/checkout — 401 anonymous (не публичный п "$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401 echo "========================================" +if [ "$noresp" -gt 0 ]; then + # Отдельная строка в итоге, а не только у самой проверки: читатель красного + # лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать + # регресс периметра там, где ответа просто не было. + echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток." + echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что" + echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)." +fi if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED" else From 134985a624ade7fedb1b9fe1f033278e38274ea3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 01:07:11 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(smoke):=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20TLS-=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20=E2=80=94=20=D1=8D=D1=82=D0=BE=20FAIL,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=C2=AB=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Повтор запроса включался на ЛЮБОМ ненулевом rc curl, хотя собственный комментарий рядом называл сетевой класс (6/28/35/52/56). Протухший, чужой или самоподписанный сертификат даёт rc=60 (замер: expired.badssl.com, self-signed.badssl.com, wrong.host.badssl.com) — и измеренный регресс периметра уезжал в колонку «периметр этой проверкой НЕ проверен», потратив на детерминированный отказ три попытки и 6 c пауз. Ровно этот отказ и есть предмет проверки 5b: без site-блока Caddy не выпускает сертификат. Коды сетевого класса вынесены в NETWORK_RC рядом с комментарием, чтобы описание и поведение не разъезжались; повтор делается только по ним. rc=7 (соединение отвергнуто) добавлен туда же — ответа при нём тоже нет. curl_failed печатает rc в обеих ветках: строки RETRY при SMOKE_ATTEMPTS=1 нет вовсе, и «домена нет» (6) было не отличить от «сертификат протух» (60). timeout-minutes 10 → 40: худший случай (прод не отвечает — мертвы все 43 проверки) = 43 × 53 c ≈ 38 мин, в 10 минут помещалось ~8 мёртвых проверок, и job убивали ДО печати FAIL-строк и итога — в том самом сценарии, ради которого правка и делалась. --- .forgejo/workflows/perimeter-smoke.yml | 29 +++++++-- scripts/smoke-mera-perimeter.sh | 84 ++++++++++++++++++++------ 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml index 17ac34bf..fe3f5c4c 100644 --- a/.forgejo/workflows/perimeter-smoke.yml +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -39,10 +39,31 @@ jobs: # проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный # прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому # что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы - # (~53 c против обычных ~2 c). Трёх таких хватило бы, чтобы упереться в - # 5 минут и job был бы убит ДО того, как напечатает строки FAIL и итог — - # то есть ровно в тот момент, когда лог нужнее всего. - timeout-minutes: 10 + # (~53 c против обычных ~2 c). + # + # 12.09.2026: 10 минут — тоже мало, и мало ровно в том сценарии, ради + # которого повтор писался. АРИФМЕТИКА ХУДШЕГО СЛУЧАЯ. Одна неотвечающая + # проверка сетевого класса = 3×15 c таймаута + 2 c и 4 c пауз ретрая + 2 c + # паузы между проверками = 53 c. Прод не отвечает целиком (DNS не + # резолвится, вход лежит) — мертвы ВСЕ проверки: 43 × 53 = 2279 c ≈ 38 мин. + # Откуда 43 (замер 12.09, зелёный прогон против прода — 43 PASS за 167 c): + # 42 обычные проверки + отдельная загрузка HTML лэндинга; 43-я, производный + # layout-чанк, при мёртвом ответе не запрашивается вовсе — запросов ровно + # столько же. + # В 10 минут помещалось ~8 мёртвых проверок из 43, дальше job убивали ДО + # печати FAIL-строк и итога — то есть лог терялся при полном отказе прода. + # + # Правка «повторяем только сетевой класс» (12.09) худший случай НЕ + # уменьшает: 15-секундный таймаут как раз сетевой (rc=28) и повторяется + # по-прежнему. Она удешевляет ДРУГОЙ сценарий — протухший/чужой сертификат + # (rc=60): отказ приходит сразу и без повторов. Замер 12.09 на + # expired.badssl.com, одна проверка при SMOKE_PAUSE=0 — 23 c на прежней + # голове (3 попытки + 6 c пауз) против <1 c теперь. + # + # 40 минут = 38 мин худшего случая + запас на чекаут и разброс сети. + # Цена промаха несимметрична: занятый раннер стоит дёшево (прогон daily + + # on-push), потерянный лог при полном отказе прода — дорого. + timeout-minutes: 40 steps: - name: Checkout repo diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 7a32bcaa..4b449ed7 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -69,8 +69,11 @@ noresp=0 # взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон # целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной # машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят -# timeout-minutes — у неотвечающей проверки цена совсем другая (до 3×15 c -# таймаута плюс паузы), и прежних 5 минут на худший случай не хватало. +# timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута +# + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай +# (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин. +# Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и +# итога, то есть ровно там, где лог нужнее всего. # Обе величины переопределяются из окружения — для отладки локально # (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп). SMOKE_PAUSE="${SMOKE_PAUSE:-2}" @@ -78,14 +81,35 @@ SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}" # curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после. # -# Признак «ответа не было» берём у самого curl — ненулевой код возврата (28 -# таймаут, 35/52/56 обрыв соединения и TLS, 6 DNS). Он строго эквивалентен +# Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО +# класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен # `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые # http_code вообще не запрашивают: до этой правки обрыв в # check_redirect_location читался как «Location не тот», а обрыв при загрузке # лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все # места сразу, а не только те две проверки, что покраснели. # +# СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте +# комментария, чтобы описание и поведение не разъехались: +# 6 — DNS не разрешился +# 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же) +# 28 — таймаут +# 35 — обрыв на TLS-хендшейке +# 52 — сервер закрыл соединение, не ответив +# 56 — обрыв при приёме ответа +# Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь — +# тот самый эффект входа, ради которого повтор и заведён. +# +# ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или +# самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com, +# self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ +# отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без +# site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо +# редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки +# дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не +# измеряли» — то есть регресс спрятался бы ровно там, где его надо показать. +NETWORK_RC=" 6 7 28 35 52 56 " + # Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не # будет; проверка отработает ровно так же, как до правки. curl_try() { @@ -94,6 +118,8 @@ curl_try() { out=$(curl "$@" 2>/dev/null) rc=$? { [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break + # Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC). + case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac # Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше). echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2 sleep "$((attempt * 2))" @@ -104,17 +130,35 @@ curl_try() { return "$rc" } -# no_response: отдельная формулировка для «ответа не было». +# curl_failed: красная строка, когда ответа не удалось получить у самого curl. # -# Это FAIL (прогон обязан покраснеть — мы действительно не знаем, цел ли -# периметр), но формулировка другая специально: читатель красного лога не -# должен искать регресс периметра там, где измерения не было вовсе. -no_response() { - local desc="$1" url="$2" - echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток;" \ - "это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)" +# За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя: +# - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ +# измерилась (плюс счётчик noresp и отдельная строка в итоге); +# - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ +# ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа. +# +# rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится +# вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от +# «сертификат протух» (60) — а это диагнозы из разных отделов. +curl_failed() { + local desc="$1" url="$2" rc="$3" reason fail=1 - noresp=$((noresp + 1)) + case "$NETWORK_RC" in + *" $rc "*) + echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \ + "это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)" + noresp=$((noresp + 1)) + return + ;; + esac + case "$rc" in + 60|51|83) reason="TLS-сертификат отвергнут" ;; + 58|77) reason="проблема с клиентским сертификатом/CA" ;; + *) reason="curl не выполнил запрос" ;; + esac + echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \ + "запрос — повтора не было, код вне сетевого класса «ответа нет»)" } check() { @@ -123,7 +167,7 @@ check() { code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" elif [ "$code" = "$expected" ]; then echo "PASS: $desc ($url -> $code)" else @@ -148,7 +192,7 @@ check_any() { code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" return fi for want in "$@"; do @@ -170,7 +214,7 @@ check_post() { -X POST -H 'Content-Type: application/json' -d "$body" "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" return fi code=${out##*$'\n'} @@ -209,7 +253,7 @@ check_redirect_location() { headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" return fi location=$(printf '%s' "$headers" \ @@ -237,7 +281,7 @@ check_content_type() { out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" return fi code=${out##*$' @@ -349,7 +393,7 @@ check_caddy_404() { out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url") rc=$? if [ "$rc" -ne 0 ]; then - no_response "$desc" "$url" + curl_failed "$desc" "$url" "$rc" return fi code=${out%% *} @@ -377,7 +421,7 @@ layout_rc=$? if [ "$layout_rc" -ne 0 ]; then # Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался # как «сам лэндинг сломан» — диагноз, которого никто не измерял. - no_response "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" + curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc" else layout_chunk=$(printf '%s' "$layout_html" \ | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)