fix(tradein/proxy): отличать ручное выключение узла от авто-выключения (#2610) #2652

Merged
bot-backend merged 1 commit from fix/tradein-proxy-disabled-reason into main 2026-08-05 10:18:03 +00:00
5 changed files with 356 additions and 40 deletions

View file

@ -2693,6 +2693,7 @@ class ProxyRow(BaseModel):
provider_affinity: str provider_affinity: str
rotate_url: str | None # маскированный rotate_url: str | None # маскированный
enabled: bool enabled: bool
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
consecutive_fails: int consecutive_fails: int
exit_ip: str | None exit_ip: str | None
latency_ms: int | None latency_ms: int | None
@ -2717,8 +2718,18 @@ def bulk_upsert_proxies(
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?, Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
"label"?, "geo"?, "operator"?}, ...]}. "label"?, "geo"?, "operator"?}, ...]}.
Существующий url DO UPDATE (affinity/kind/rotate_url + enabled=true, Существующий url DO UPDATE (affinity/kind/rotate_url + enabled,
label/geo/operator обновляются если переданы). Новый INSERT. 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. Валидация provider_affinity/kind по whitelist на уровне Pydantic 422.
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
@ -2744,7 +2755,10 @@ def bulk_upsert_proxies(
label = COALESCE(EXCLUDED.label, scrape_proxies.label), label = COALESCE(EXCLUDED.label, scrape_proxies.label),
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo), geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator), 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() updated_at = now()
RETURNING (xmax = 0) AS was_inserted RETURNING (xmax = 0) AS was_inserted
""" """
@ -2793,8 +2807,9 @@ def list_proxies(
text( text(
f""" f"""
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled, SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, disabled_reason, consecutive_fails, exit_ip, latency_ms,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
expires_at, created_at, updated_at
FROM scrape_proxies FROM scrape_proxies
{where} {where}
ORDER BY provider_affinity, id ORDER BY provider_affinity, id
@ -2819,6 +2834,7 @@ def list_proxies(
provider_affinity=r["provider_affinity"], provider_affinity=r["provider_affinity"],
rotate_url=_mask_proxy_url(r["rotate_url"]), rotate_url=_mask_proxy_url(r["rotate_url"]),
enabled=r["enabled"], enabled=r["enabled"],
disabled_reason=r["disabled_reason"],
consecutive_fails=r["consecutive_fails"], consecutive_fails=r["consecutive_fails"],
exit_ip=r["exit_ip"], exit_ip=r["exit_ip"],
latency_ms=r["latency_ms"], latency_ms=r["latency_ms"],
@ -2838,6 +2854,18 @@ def list_proxies(
class ProxyPatch(BaseModel): class ProxyPatch(BaseModel):
enabled: bool 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) @router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
@ -2846,20 +2874,40 @@ def patch_proxy(
payload: ProxyPatch, payload: ProxyPatch,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> ProxyRow: ) -> 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 = ( row = (
db.execute( db.execute(
text( text(
""" """
UPDATE scrape_proxies 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 WHERE id = :id
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled, RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, disabled_reason, consecutive_fails, exit_ip, latency_ms,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at 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() .mappings()
.fetchone() .fetchone()
@ -2867,6 +2915,13 @@ def patch_proxy(
if row is None: if row is None:
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found") raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
db.commit() 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: def _iso(v: Any) -> str | None:
return v.isoformat() if v is not None else None return v.isoformat() if v is not None else None
@ -2879,6 +2934,7 @@ def patch_proxy(
provider_affinity=row["provider_affinity"], provider_affinity=row["provider_affinity"],
rotate_url=_mask_proxy_url(row["rotate_url"]), rotate_url=_mask_proxy_url(row["rotate_url"]),
enabled=row["enabled"], enabled=row["enabled"],
disabled_reason=row["disabled_reason"],
consecutive_fails=row["consecutive_fails"], consecutive_fails=row["consecutive_fails"],
exit_ip=row["exit_ip"], exit_ip=row["exit_ip"],
latency_ms=row["latency_ms"], latency_ms=row["latency_ms"],

View file

@ -34,6 +34,18 @@ Self-healing (#2600):
enabled-узел выделенной affinity (пример domclick, один узел на всё, см. acquire enabled-узел выделенной affinity (пример domclick, один узел на всё, см. acquire
docstring) иначе чинили бы один источник ценой полной поломки другого. 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): Sticky session lease (browser-путь, живая регрессия 2026-08):
- `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии - `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии
(весь прогон), а не на каждый `/fetch` иначе при N>=2 живых узлах пула каждый (весь прогон), а не на каждый `/fetch` иначе при N>=2 живых узлах пула каждый
@ -305,12 +317,19 @@ def mark_health(
) -> None: ) -> None:
"""Записать результат health-check'а прокси. """Записать результат health-check'а прокси.
ok=True consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/ ok=True consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
last_check_at/exit_ip/latency_ms. enabled=true безусловно это реанимация: exit_ip/latency_ms. enabled=true РЕАНИМАЦИЯ, но ТОЛЬКО если узел не
узел, ранее выключенный auto-disable'ом, возвращается в строй первой же выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный
успешной пробой (см. run_proxy_healthcheck, #2600 п.1). auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же
успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором
(disabled_reason НЕ NULL), остаётся enabled=false иначе снятый с ротации
забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify
площадку не эмулирует, значит бан ею не ловится). Этот случай логируется
WARNING'ом — раньше (до #2610) происходил молча.
ok=False consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси 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" / fail_kind необязательная классификация неуспеха ("timeout" / "connect_error" /
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования. "http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
@ -321,22 +340,36 @@ def mark_health(
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее. рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
""" """
if ok: if ok:
db.execute( row = (
text( db.execute(
""" text(
UPDATE scrape_proxies """
SET consecutive_fails = 0, UPDATE scrape_proxies
last_ok_at = now(), SET consecutive_fails = 0,
last_check_at = now(), last_ok_at = now(),
exit_ip = CAST(:exit_ip AS text), last_check_at = now(),
latency_ms = CAST(:latency_ms AS integer), exit_ip = CAST(:exit_ip AS text),
enabled = true, latency_ms = CAST(:latency_ms AS integer),
updated_at = now() enabled = CASE
WHERE id = CAST(:id AS bigint) WHEN disabled_reason IS NULL THEN true ELSE enabled
""" END,
), updated_at = now()
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id}, 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: else:
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла). # consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
db.execute( db.execute(
@ -453,7 +486,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
назад. Без этого auto-disable необратим узел, ушедший в disable из-за транзиентного назад. Без этого auto-disable необратим узел, ушедший в disable из-за транзиентного
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
disabled-узла реанимирует его (enabled=true через mark_health) инкрементит `revived` 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 один и тот же upstream-endpoint (ipify) не нужен. Returns counters
@ -465,7 +501,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute( db.execute(
text( text(
""" """
SELECT id, url, kind, enabled SELECT id, url, kind, enabled, disabled_reason
FROM scrape_proxies FROM scrape_proxies
WHERE enabled WHERE enabled
OR last_check_at IS NULL 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"]) proxy_id = int(row["id"])
url = str(row["url"]) url = str(row["url"])
was_disabled = not bool(row["enabled"]) 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) 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) mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
checked += 1 checked += 1
if ok: if ok:
ok_count += 1 ok_count += 1
if was_disabled: # manually_disabled → mark_health не тронул enabled (см. её WARNING-лог);
# revived считает только реальное авто-воскрешение (#2610).
if was_disabled and not manually_disabled:
revived += 1 revived += 1
logger.info( logger.info(
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, " "proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "

View file

@ -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;

View file

@ -20,6 +20,12 @@ reap_stale_leases проверяются по фактическому изме
- run_proxy_healthcheck: disabled-узлы самовосстановление (#2600 п.1): - run_proxy_healthcheck: disabled-узлы самовосстановление (#2600 п.1):
* успешная проба выключенного узла возвращает его в строй + revived++; * успешная проба выключенного узла возвращает его в строй + 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 from __future__ import annotations
@ -177,12 +183,18 @@ class FakeSession:
row["latency_ms"] = p["latency_ms"] row["latency_ms"] = p["latency_ms"]
row["last_ok_at"] = datetime.now(UTC) row["last_ok_at"] = datetime.now(UTC)
row["last_check_at"] = datetime.now(UTC) row["last_check_at"] = datetime.now(UTC)
# "SET consecutive_fails = 0" — общая подстрока старого И нового SQL, # Ручное выключение (#2610): реальный SQL реанимирует (enabled=true)
# НЕ различает их сама по себе. Реанимация (enabled=true) — только если # ТОЛЬКО если disabled_reason IS NULL — мок обязан честно это
# в тексте запроса реально есть присвоение enabled (#2600 review: старый # воспроизвести, иначе тест не поймает регресс "снова безусловно
# мок ставил enabled=True безусловно и не ловил регресс). # enabled=true" (тот же класс бага, что был с "enabled" in sql до
if "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 row["enabled"] = True
if "RETURNING disabled_reason" in sql:
return _FakeResult([{"disabled_reason": row.get("disabled_reason")}])
return _FakeResult([]) return _FakeResult([])
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
@ -226,6 +238,7 @@ def _proxy(
last_check_at: datetime | None = None, last_check_at: datetime | None = None,
kind: str = "http", kind: str = "http",
rotate_url: str | None = None, rotate_url: str | None = None,
disabled_reason: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"id": pid, "id": pid,
@ -234,6 +247,7 @@ def _proxy(
"rotate_url": rotate_url, "rotate_url": rotate_url,
"provider_affinity": affinity, "provider_affinity": affinity,
"enabled": enabled, "enabled": enabled,
"disabled_reason": disabled_reason,
"consecutive_fails": fails, "consecutive_fails": fails,
"leased_by": leased_by, "leased_by": leased_by,
"leased_at": leased_at, "leased_at": leased_at,
@ -397,6 +411,37 @@ def test_mark_health_ok_revives_disabled_proxy() -> None:
assert row["consecutive_fails"] == 0 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 ──────────────────────────────────────────────────────── # ── reap_stale_leases ────────────────────────────────────────────────────────
@ -533,6 +578,39 @@ async def test_healthcheck_revives_disabled_proxy_on_success(
assert row["consecutive_fails"] == 0 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( async def test_healthcheck_skips_recently_checked_disabled_proxy(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:

View file

@ -1,9 +1,11 @@
"""Offline-тесты admin proxy-pool ручек (#2161). """Offline-тесты admin proxy-pool ручек (#2161, #2610).
Покрытие (db мокается, NO live network/DB): Покрытие (db мокается, NO live network/DB):
- POST /api/v1/admin/proxies/bulk UPSERT-счётчики, валидация affinity/kind - 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 - 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 from __future__ import annotations
@ -133,6 +135,7 @@ def _proxy_db_row(**over: Any) -> dict[str, Any]:
"provider_affinity": "avito", "provider_affinity": "avito",
"rotate_url": "http://user:secret@rot:9000/changeip", "rotate_url": "http://user:secret@rot:9000/changeip",
"enabled": True, "enabled": True,
"disabled_reason": None,
"consecutive_fails": 0, "consecutive_fails": 0,
"exit_ip": "1.2.3.4", "exit_ip": "1.2.3.4",
"latency_ms": 120, "latency_ms": 120,
@ -164,6 +167,22 @@ def test_list_masks_password(client: TestClient, db: MagicMock) -> None:
assert "secret" not in r.text 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: def test_list_passes_filters(client: TestClient, db: MagicMock) -> None:
result = MagicMock() result = MagicMock()
result.mappings.return_value.all.return_value = [] 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}) r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True})
assert r.status_code == 404 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