fix(tradein/proxy): отличать ручное выключение узла от авто-выключения (#2610) (#2652)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m17s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m17s
This commit is contained in:
parent
5162659277
commit
aa5bb76822
5 changed files with 356 additions and 40 deletions
|
|
@ -2646,6 +2646,7 @@ class ProxyRow(BaseModel):
|
|||
provider_affinity: str
|
||||
rotate_url: str | None # маскированный
|
||||
enabled: bool
|
||||
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
|
||||
consecutive_fails: int
|
||||
exit_ip: str | None
|
||||
latency_ms: int | None
|
||||
|
|
@ -2670,8 +2671,18 @@ def bulk_upsert_proxies(
|
|||
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
|
||||
"label"?, "geo"?, "operator"?}, ...]}.
|
||||
|
||||
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled=true,
|
||||
label/geo/operator обновляются если переданы). Новый → INSERT.
|
||||
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled,
|
||||
label/geo/operator обновляются если переданы). Новый → INSERT (enabled=true,
|
||||
disabled_reason=NULL — новый прокси не может быть "выключен вручную").
|
||||
|
||||
enabled на UPDATE-ветке НЕ безусловный (#2610): если у существующей строки
|
||||
disabled_reason НЕ NULL (оператор снял узел с ротации вручную), bulk-upsert
|
||||
(например повторный прогон загрузчика с тем же url) не должен тихо вернуть
|
||||
его в строй — тот же класс бага, что чинили в mark_health. enabled=true
|
||||
ставится, только если disabled_reason IS NULL; сам disabled_reason bulk
|
||||
не трогает (эта ручка не умеет ни ставить, ни снимать ручной флаг — это
|
||||
PATCH /proxies/{id}, см. patch_proxy).
|
||||
|
||||
Валидация provider_affinity/kind по whitelist на уровне Pydantic → 422.
|
||||
|
||||
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
|
||||
|
|
@ -2697,7 +2708,10 @@ def bulk_upsert_proxies(
|
|||
label = COALESCE(EXCLUDED.label, scrape_proxies.label),
|
||||
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
|
||||
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator),
|
||||
enabled = true,
|
||||
enabled = CASE
|
||||
WHEN scrape_proxies.disabled_reason IS NULL THEN true
|
||||
ELSE scrape_proxies.enabled
|
||||
END,
|
||||
updated_at = now()
|
||||
RETURNING (xmax = 0) AS was_inserted
|
||||
"""
|
||||
|
|
@ -2746,8 +2760,9 @@ def list_proxies(
|
|||
text(
|
||||
f"""
|
||||
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
||||
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
|
||||
expires_at, created_at, updated_at
|
||||
FROM scrape_proxies
|
||||
{where}
|
||||
ORDER BY provider_affinity, id
|
||||
|
|
@ -2772,6 +2787,7 @@ def list_proxies(
|
|||
provider_affinity=r["provider_affinity"],
|
||||
rotate_url=_mask_proxy_url(r["rotate_url"]),
|
||||
enabled=r["enabled"],
|
||||
disabled_reason=r["disabled_reason"],
|
||||
consecutive_fails=r["consecutive_fails"],
|
||||
exit_ip=r["exit_ip"],
|
||||
latency_ms=r["latency_ms"],
|
||||
|
|
@ -2791,6 +2807,18 @@ def list_proxies(
|
|||
|
||||
class ProxyPatch(BaseModel):
|
||||
enabled: bool
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
max_length=500,
|
||||
description=(
|
||||
"Причина ручного выключения (#2610). Используется только когда enabled=false; "
|
||||
"при отсутствии подставляется дефолтный текст. Игнорируется при enabled=true — "
|
||||
"включение всегда сбрасывает disabled_reason в NULL."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_MANUAL_DISABLE_REASON = "manually disabled via admin API"
|
||||
|
||||
|
||||
@router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
|
||||
|
|
@ -2799,20 +2827,40 @@ def patch_proxy(
|
|||
payload: ProxyPatch,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ProxyRow:
|
||||
"""Enable/disable одного прокси по id. 404 если не найден."""
|
||||
"""Enable/disable одного прокси по id. 404 если не найден.
|
||||
|
||||
#2610: разводит "ручное выключение оператором" от "авто-выключение пулом".
|
||||
enabled=false → disabled_reason ставится (payload.reason либо дефолтный текст) —
|
||||
mark_health(ok=True) больше не воскресит узел молча первой успешной ipify-пробой.
|
||||
enabled=true → disabled_reason ОБЯЗАТЕЛЬНО сбрасывается в NULL — иначе узел,
|
||||
однажды выключенный руками, никогда больше не участвовал бы в авто-восстановлении
|
||||
(см. proxy_pool.mark_health).
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies
|
||||
SET enabled = :enabled, updated_at = now()
|
||||
SET enabled = :enabled,
|
||||
disabled_reason = CASE
|
||||
WHEN CAST(:enabled AS boolean) THEN NULL
|
||||
ELSE COALESCE(CAST(:reason AS text), disabled_reason,
|
||||
CAST(:default_reason AS text))
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
||||
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
|
||||
expires_at, created_at, updated_at
|
||||
"""
|
||||
),
|
||||
{"enabled": payload.enabled, "id": proxy_id},
|
||||
{
|
||||
"enabled": payload.enabled,
|
||||
"reason": payload.reason,
|
||||
"default_reason": _DEFAULT_MANUAL_DISABLE_REASON,
|
||||
"id": proxy_id,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
|
|
@ -2820,6 +2868,13 @@ def patch_proxy(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
|
||||
db.commit()
|
||||
if not payload.enabled:
|
||||
logger.info(
|
||||
"proxy_pool: proxy id=%d manually disabled via admin API (reason=%r) — "
|
||||
"auto-revive suspended until re-enabled (#2610)",
|
||||
proxy_id,
|
||||
row["disabled_reason"],
|
||||
)
|
||||
|
||||
def _iso(v: Any) -> str | None:
|
||||
return v.isoformat() if v is not None else None
|
||||
|
|
@ -2832,6 +2887,7 @@ def patch_proxy(
|
|||
provider_affinity=row["provider_affinity"],
|
||||
rotate_url=_mask_proxy_url(row["rotate_url"]),
|
||||
enabled=row["enabled"],
|
||||
disabled_reason=row["disabled_reason"],
|
||||
consecutive_fails=row["consecutive_fails"],
|
||||
exit_ip=row["exit_ip"],
|
||||
latency_ms=row["latency_ms"],
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ Self-healing (#2600):
|
|||
enabled-узел выделенной affinity (пример — domclick, один узел на всё, см. acquire
|
||||
docstring) — иначе чинили бы один источник ценой полной поломки другого.
|
||||
|
||||
Ручное выключение vs авто-выключение (#2610):
|
||||
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
|
||||
enabled=false: пул выключил сам после серии сбоев (disabled_reason IS NULL) —
|
||||
воскрешается первой же успешной пробой, как задумано #2609; оператор выключил
|
||||
руками через admin API (disabled_reason НЕ NULL) — mark_health(ok=True) НЕ
|
||||
трогает enabled, пишет WARNING с id узла и причиной. Без этого узел, снятый
|
||||
оператором из ротации (например забаненный площадкой — ipify через него всё
|
||||
равно отвечает 200), возвращался бы в строй первой же health-пробой молча.
|
||||
- Сброс флага (возврат к авто-восстанавливаемому состоянию) — только через
|
||||
admin API PATCH /proxies/{id} enabled=true (app/api/v1/admin.py:patch_proxy),
|
||||
который явно обнуляет disabled_reason в NULL.
|
||||
|
||||
Sticky session lease (browser-путь, живая регрессия 2026-08):
|
||||
- `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии
|
||||
(весь прогон), а не на каждый `/fetch` — иначе при N>=2 живых узлах пула каждый
|
||||
|
|
@ -305,12 +317,19 @@ def mark_health(
|
|||
) -> None:
|
||||
"""Записать результат health-check'а прокси.
|
||||
|
||||
ok=True → consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/
|
||||
last_check_at/exit_ip/latency_ms. enabled=true безусловно — это реанимация:
|
||||
узел, ранее выключенный auto-disable'ом, возвращается в строй первой же
|
||||
успешной пробой (см. run_proxy_healthcheck, #2600 п.1).
|
||||
ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
|
||||
exit_ip/latency_ms. enabled=true — РЕАНИМАЦИЯ, но ТОЛЬКО если узел не
|
||||
выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный
|
||||
auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же
|
||||
успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором
|
||||
(disabled_reason НЕ NULL), остаётся enabled=false — иначе снятый с ротации
|
||||
забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify
|
||||
площадку не эмулирует, значит бан ею не ловится). Этот случай логируется
|
||||
WARNING'ом — раньше (до #2610) происходил молча.
|
||||
ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
||||
авто-disable (enabled=false). last_check_at обновляется в любом случае.
|
||||
авто-disable (enabled=false, disabled_reason НЕ трогается — узел уходит в
|
||||
disable БЕЗ причины, т.е. остаётся авто-воскрешаемым). last_check_at
|
||||
обновляется в любом случае.
|
||||
|
||||
fail_kind — необязательная классификация неуспеха ("timeout" / "connect_error" /
|
||||
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
|
||||
|
|
@ -321,22 +340,36 @@ def mark_health(
|
|||
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
|
||||
"""
|
||||
if ok:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies
|
||||
SET consecutive_fails = 0,
|
||||
last_ok_at = now(),
|
||||
last_check_at = now(),
|
||||
exit_ip = CAST(:exit_ip AS text),
|
||||
latency_ms = CAST(:latency_ms AS integer),
|
||||
enabled = true,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
"""
|
||||
),
|
||||
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies
|
||||
SET consecutive_fails = 0,
|
||||
last_ok_at = now(),
|
||||
last_check_at = now(),
|
||||
exit_ip = CAST(:exit_ip AS text),
|
||||
latency_ms = CAST(:latency_ms AS integer),
|
||||
enabled = CASE
|
||||
WHEN disabled_reason IS NULL THEN true ELSE enabled
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING disabled_reason
|
||||
"""
|
||||
),
|
||||
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if row is not None and row["disabled_reason"] is not None:
|
||||
logger.warning(
|
||||
"proxy_pool: mark_health id=%d ok=True but stays disabled — manually "
|
||||
"disabled (reason=%r), auto-revive skipped (#2610)",
|
||||
proxy_id,
|
||||
row["disabled_reason"],
|
||||
)
|
||||
else:
|
||||
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
|
||||
db.execute(
|
||||
|
|
@ -453,7 +486,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
назад. Без этого auto-disable необратим — узел, ушедший в disable из-за транзиентного
|
||||
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
|
||||
disabled-узла реанимирует его (enabled=true через mark_health) — инкрементит `revived`
|
||||
и пишет отдельный INFO-лог.
|
||||
и пишет отдельный INFO-лог. Ручно-выключенные узлы (disabled_reason НЕ NULL, #2610)
|
||||
тоже пробуются (чтобы после ручного включения признак немедленно ожил без ожидания
|
||||
следующего disable/enable цикла), но mark_health их не воскрешает — revived не растёт,
|
||||
WARNING пишет сам mark_health.
|
||||
|
||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||
|
|
@ -465,7 +501,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind, enabled
|
||||
SELECT id, url, kind, enabled, disabled_reason
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
OR last_check_at IS NULL
|
||||
|
|
@ -489,12 +525,15 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
proxy_id = int(row["id"])
|
||||
url = str(row["url"])
|
||||
was_disabled = not bool(row["enabled"])
|
||||
manually_disabled = row["disabled_reason"] is not None
|
||||
ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url)
|
||||
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
|
||||
checked += 1
|
||||
if ok:
|
||||
ok_count += 1
|
||||
if was_disabled:
|
||||
# manually_disabled → mark_health не тронул enabled (см. её WARNING-лог);
|
||||
# revived считает только реальное авто-воскрешение (#2610).
|
||||
if was_disabled and not manually_disabled:
|
||||
revived += 1
|
||||
logger.info(
|
||||
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
-- 209_scrape_proxies_disabled_reason.sql
|
||||
-- scrape_proxies.disabled_reason — различить "оператор выключил руками" от
|
||||
-- "пул выключил сам после серии сбоев" (#2610).
|
||||
--
|
||||
-- WHY:
|
||||
-- #2609 сделало mark_health(ok=True) безусловно ставить enabled=true — чинит
|
||||
-- реальный прод-инцидент (авто-выключенный после серии сбоев узел иначе не
|
||||
-- проверяется больше никогда, транзиентный сбой = вечный приговор). Побочка:
|
||||
-- оператор снимает узел с ротации через PATCH /proxies/{id} (enabled=false) —
|
||||
-- первая же успешная ipify-проба молча возвращает его обратно. Особенно больно,
|
||||
-- когда узел забанен площадкой (Авито): ipify через него ходит чисто (ipify
|
||||
-- никого не банит), значит узел гарантированно вернётся, и оператор снимает
|
||||
-- его снова и снова.
|
||||
--
|
||||
-- WHAT:
|
||||
-- disabled_reason text, NULL по умолчанию. Семантика:
|
||||
-- NULL — узел не выключен вручную (авто-disable по consecutive_fails —
|
||||
-- тоже NULL здесь; поведение #2609 "авто-выключенный воскресает
|
||||
-- первой успешной пробой" сохраняется без изменений).
|
||||
-- текст — причина ручного выключения (по умолчанию из admin API —
|
||||
-- "manually disabled via admin API", либо то, что передал оператор).
|
||||
-- mark_health(ok=True) НЕ трогает enabled, пока это поле не NULL.
|
||||
-- Сбрасывается в NULL ручным включением (PATCH .../{id} enabled=true).
|
||||
--
|
||||
-- ВЫБОР text, а не boolean manually_disabled: единственное текстовое поле
|
||||
-- расширяемо под будущую ТРЕТЬЮ причину выключения без новой миграции — #2600
|
||||
-- п.1 (сигнал бана площадкой до пула) заведёт значение вида 'banned:avito',
|
||||
-- отличимое от 'manual' в логах/админке, и по той же самой колонке (не-NULL =
|
||||
-- не воскрешать по голому ipify) без ALTER TABLE. Boolean такого не даёт —
|
||||
-- потребовал бы либо второй колонки, либо потери "почему" при разборе.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - BEGIN/COMMIT — атомарно.
|
||||
-- - ADD COLUMN IF NOT EXISTS — повторный прогон no-op (auto-apply strict).
|
||||
-- - Без DEFAULT — существующие строки получают NULL, т.е. "не выключены
|
||||
-- руками" — обратная совместимость: ни одна прод-строка не станет ручной.
|
||||
--
|
||||
-- Dependencies: 157_scrape_proxies.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE scrape_proxies
|
||||
ADD COLUMN IF NOT EXISTS disabled_reason text;
|
||||
|
||||
COMMENT ON COLUMN scrape_proxies.disabled_reason IS
|
||||
'NULL = не выключен вручную (mark_health(ok=True) может авто-восстановить '
|
||||
'enabled=true, #2609). Текст = причина ручного выключения оператором '
|
||||
'(PATCH /proxies/{id} enabled=false, admin.py) — ipify-проба узел НЕ '
|
||||
'воскрешает, пока поле не NULL. Сбрасывается в NULL ручным включением. '
|
||||
'Задел под #2600 п.1 (сигнал бана площадкой до пула) — третья причина '
|
||||
'ляжет сюда же без новой миграции, напр. значением вида ''banned:avito''.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -20,6 +20,12 @@ reap_stale_leases проверяются по фактическому изме
|
|||
- run_proxy_healthcheck: disabled-узлы — самовосстановление (#2600 п.1):
|
||||
* успешная проба выключенного узла возвращает его в строй + revived++;
|
||||
* недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера).
|
||||
- mark_health / disabled_reason (#2610 — ручное vs авто-выключение):
|
||||
* авто-выключенный узел (disabled_reason IS NULL) по-прежнему воскресает
|
||||
успешной пробой — поведение #2609 не сломано;
|
||||
* ручно-выключенный узел (disabled_reason НЕ NULL) НЕ воскресает даже при
|
||||
ok=True, и это логируется (WARNING);
|
||||
* run_proxy_healthcheck не считает ручно-выключенный узел в revived.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -177,12 +183,18 @@ class FakeSession:
|
|||
row["latency_ms"] = p["latency_ms"]
|
||||
row["last_ok_at"] = datetime.now(UTC)
|
||||
row["last_check_at"] = datetime.now(UTC)
|
||||
# "SET consecutive_fails = 0" — общая подстрока старого И нового SQL,
|
||||
# НЕ различает их сама по себе. Реанимация (enabled=true) — только если
|
||||
# в тексте запроса реально есть присвоение enabled (#2600 review: старый
|
||||
# мок ставил enabled=True безусловно и не ловил регресс).
|
||||
if "enabled" in sql:
|
||||
# Ручное выключение (#2610): реальный SQL реанимирует (enabled=true)
|
||||
# ТОЛЬКО если disabled_reason IS NULL — мок обязан честно это
|
||||
# воспроизвести, иначе тест не поймает регресс "снова безусловно
|
||||
# enabled=true" (тот же класс бага, что был с "enabled" in sql до
|
||||
# #2600 review).
|
||||
if "disabled_reason IS NULL" in sql:
|
||||
if row.get("disabled_reason") is None:
|
||||
row["enabled"] = True
|
||||
elif "enabled" in sql:
|
||||
row["enabled"] = True
|
||||
if "RETURNING disabled_reason" in sql:
|
||||
return _FakeResult([{"disabled_reason": row.get("disabled_reason")}])
|
||||
return _FakeResult([])
|
||||
|
||||
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
|
||||
|
|
@ -226,6 +238,7 @@ def _proxy(
|
|||
last_check_at: datetime | None = None,
|
||||
kind: str = "http",
|
||||
rotate_url: str | None = None,
|
||||
disabled_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": pid,
|
||||
|
|
@ -234,6 +247,7 @@ def _proxy(
|
|||
"rotate_url": rotate_url,
|
||||
"provider_affinity": affinity,
|
||||
"enabled": enabled,
|
||||
"disabled_reason": disabled_reason,
|
||||
"consecutive_fails": fails,
|
||||
"leased_by": leased_by,
|
||||
"leased_at": leased_at,
|
||||
|
|
@ -397,6 +411,37 @@ def test_mark_health_ok_revives_disabled_proxy() -> None:
|
|||
assert row["consecutive_fails"] == 0
|
||||
|
||||
|
||||
# ── mark_health / disabled_reason (#2610 — ручное vs авто-выключение) ──────────
|
||||
#
|
||||
# red/green контракт issue: (a) авто-выключенный узел воскресает по успешной пробе
|
||||
# (#2609 не сломан — дублирует test_mark_health_ok_revives_disabled_proxy выше, но
|
||||
# явно рядом с (b)/(c) для контраста), (b) ручно-выключенный НЕ воскресает + лог,
|
||||
# (c) ручное включение сбрасывает флаг → узел снова авто-восстанавливаем (уровень
|
||||
# admin API — см. tests/test_admin_proxies.py, mark_health сам флаг не трогает).
|
||||
|
||||
|
||||
def test_mark_health_ok_revives_auto_disabled_proxy_a() -> None:
|
||||
"""(a) Авто-выключенный (disabled_reason=NULL) воскресает — поведение #2609 сохранено."""
|
||||
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason=None)])
|
||||
mark_health(db, 1, ok=True) # type: ignore[arg-type]
|
||||
row = db._by_id(1)
|
||||
assert row["enabled"] is True
|
||||
assert row["consecutive_fails"] == 0
|
||||
|
||||
|
||||
def test_mark_health_ok_does_not_revive_manually_disabled_proxy_b(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""(b) Ручно-выключенный (disabled_reason НЕ NULL) НЕ воскресает — и это логируется."""
|
||||
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason="manual")])
|
||||
with caplog.at_level("WARNING"):
|
||||
mark_health(db, 1, ok=True) # type: ignore[arg-type]
|
||||
row = db._by_id(1)
|
||||
assert row["enabled"] is False # НЕ реанимирован, несмотря на ok=True
|
||||
assert row["consecutive_fails"] == 0 # fails всё равно сбрасывается пробой
|
||||
assert any("manually disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ── reap_stale_leases ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -533,6 +578,39 @@ async def test_healthcheck_revives_disabled_proxy_on_success(
|
|||
assert row["consecutive_fails"] == 0
|
||||
|
||||
|
||||
async def test_healthcheck_does_not_revive_manually_disabled_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""(b)/(с) на уровне healthcheck: ручно-выключенный узел пробуется (recheck наступил),
|
||||
но остаётся disabled даже при успешной пробе — revived НЕ растёт (#2610)."""
|
||||
stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(
|
||||
1,
|
||||
enabled=False,
|
||||
fails=DISABLE_THRESHOLD,
|
||||
last_check_at=stale_check,
|
||||
disabled_reason="manual",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||
return True, "5.5.5.5", 30, None
|
||||
|
||||
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
|
||||
|
||||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||
|
||||
assert counters["checked"] == 1
|
||||
assert counters["ok"] == 1
|
||||
assert counters["revived"] == 0 # ручной флаг — mark_health не воскресил
|
||||
row = db._by_id(1)
|
||||
assert row["enabled"] is False # остался выключенным
|
||||
assert row["consecutive_fails"] == 0 # проба всё равно сбросила счётчик fails
|
||||
|
||||
|
||||
async def test_healthcheck_skips_recently_checked_disabled_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Offline-тесты admin proxy-pool ручек (#2161).
|
||||
"""Offline-тесты admin proxy-pool ручек (#2161, #2610).
|
||||
|
||||
Покрытие (db мокается, NO live network/DB):
|
||||
- POST /api/v1/admin/proxies/bulk — UPSERT-счётчики, валидация affinity/kind
|
||||
- GET /api/v1/admin/proxies — маскировка пароля, фильтры
|
||||
- GET /api/v1/admin/proxies — маскировка пароля, фильтры, disabled_reason в ответе
|
||||
- PATCH /api/v1/admin/proxies/{id} — enable/disable, 404
|
||||
- #2610: PATCH enabled=false ставит disabled_reason (ручное выключение отличимо от
|
||||
авто); PATCH enabled=true сбрасывает disabled_reason в NULL (снова авто-восстанавливаем)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -133,6 +135,7 @@ def _proxy_db_row(**over: Any) -> dict[str, Any]:
|
|||
"provider_affinity": "avito",
|
||||
"rotate_url": "http://user:secret@rot:9000/changeip",
|
||||
"enabled": True,
|
||||
"disabled_reason": None,
|
||||
"consecutive_fails": 0,
|
||||
"exit_ip": "1.2.3.4",
|
||||
"latency_ms": 120,
|
||||
|
|
@ -164,6 +167,22 @@ def test_list_masks_password(client: TestClient, db: MagicMock) -> None:
|
|||
assert "secret" not in r.text
|
||||
|
||||
|
||||
def test_list_exposes_disabled_reason(client: TestClient, db: MagicMock) -> None:
|
||||
"""(#2610) Оператор должен видеть причину ручного выключения в листинге."""
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.all.return_value = [
|
||||
_proxy_db_row(id=1, enabled=False, disabled_reason=None),
|
||||
_proxy_db_row(id=2, enabled=False, disabled_reason="забанен Авито"),
|
||||
]
|
||||
db.execute.return_value = result
|
||||
|
||||
r = client.get("/api/v1/admin/proxies")
|
||||
assert r.status_code == 200, r.text
|
||||
rows = {row["id"]: row for row in r.json()}
|
||||
assert rows[1]["disabled_reason"] is None # авто-выключен
|
||||
assert rows[2]["disabled_reason"] == "забанен Авито" # выключен вручную
|
||||
|
||||
|
||||
def test_list_passes_filters(client: TestClient, db: MagicMock) -> None:
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.all.return_value = []
|
||||
|
|
@ -197,3 +216,74 @@ def test_patch_not_found_404(client: TestClient, db: MagicMock) -> None:
|
|||
|
||||
r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── PATCH /proxies/{id} — disabled_reason (#2610) ───────────────────────────
|
||||
|
||||
|
||||
def test_patch_disable_sets_disabled_reason_default(client: TestClient, db: MagicMock) -> None:
|
||||
"""(#2610) Ручное выключение без явной причины — ставится дефолтный текст."""
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
|
||||
enabled=False, disabled_reason="manually disabled via admin API"
|
||||
)
|
||||
db.execute.return_value = result
|
||||
|
||||
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["disabled_reason"] == "manually disabled via admin API"
|
||||
# дефолтная причина реально передана в SQL как fallback-параметр
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["default_reason"]
|
||||
assert params["reason"] is None
|
||||
|
||||
|
||||
def test_patch_disable_sets_disabled_reason_custom(client: TestClient, db: MagicMock) -> None:
|
||||
"""(#2610) Оператор передаёт свою причину — она уходит в SQL-параметры."""
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
|
||||
enabled=False, disabled_reason="забанен Авито"
|
||||
)
|
||||
db.execute.return_value = result
|
||||
|
||||
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False, "reason": "забанен Авито"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["disabled_reason"] == "забанен Авито"
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["reason"] == "забанен Авито"
|
||||
|
||||
|
||||
def test_patch_enable_clears_disabled_reason(client: TestClient, db: MagicMock) -> None:
|
||||
"""(#2610, red/green c) Ручное включение сбрасывает disabled_reason → узел снова
|
||||
авто-восстанавливаем через mark_health."""
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
|
||||
enabled=True, disabled_reason=None
|
||||
)
|
||||
db.execute.return_value = result
|
||||
|
||||
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["disabled_reason"] is None
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["enabled"] is True
|
||||
|
||||
|
||||
# ── POST /proxies/bulk — не глушит ручной disable (#2610) ──────────────────
|
||||
|
||||
|
||||
def test_bulk_upsert_sql_preserves_manual_disable_on_conflict(
|
||||
client: TestClient, db: MagicMock
|
||||
) -> None:
|
||||
"""Bulk-upsert (например повторный прогон загрузчика) не должен молча вернуть в
|
||||
строй узел, выключенный оператором вручную — ON CONFLICT SET enabled должен
|
||||
проверять disabled_reason существующей строки, а не ставить true безусловно."""
|
||||
db.execute.side_effect = [_scalar_result(False)]
|
||||
r = client.post(
|
||||
"/api/v1/admin/proxies/bulk",
|
||||
json={"proxies": [{"url": "http://u:p@host1:8080", "provider_affinity": "avito"}]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
sql_text = str(db.execute.call_args.args[0])
|
||||
assert "disabled_reason IS NULL" in sql_text
|
||||
assert "scrape_proxies.enabled" in sql_text # fallback — не трогает при ручном disable
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue