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
|
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
|
||||||
|
|
@ -2670,8 +2671,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 ВНУТРИ одного запроса
|
||||||
|
|
@ -2697,7 +2708,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
|
||||||
"""
|
"""
|
||||||
|
|
@ -2746,8 +2760,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
|
||||||
|
|
@ -2772,6 +2787,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"],
|
||||||
|
|
@ -2791,6 +2807,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)
|
||||||
|
|
@ -2799,20 +2827,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()
|
||||||
|
|
@ -2820,6 +2868,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
|
||||||
|
|
@ -2832,6 +2887,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"],
|
||||||
|
|
|
||||||
|
|
@ -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,6 +340,7 @@ def mark_health(
|
||||||
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
|
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
|
||||||
"""
|
"""
|
||||||
if ok:
|
if ok:
|
||||||
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -330,13 +350,26 @@ def mark_health(
|
||||||
last_check_at = now(),
|
last_check_at = now(),
|
||||||
exit_ip = CAST(:exit_ip AS text),
|
exit_ip = CAST(:exit_ip AS text),
|
||||||
latency_ms = CAST(:latency_ms AS integer),
|
latency_ms = CAST(:latency_ms AS integer),
|
||||||
enabled = true,
|
enabled = CASE
|
||||||
|
WHEN disabled_reason IS NULL THEN true ELSE enabled
|
||||||
|
END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = CAST(:id AS bigint)
|
WHERE id = CAST(:id AS bigint)
|
||||||
|
RETURNING disabled_reason
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
|
{"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, "
|
||||||
|
|
|
||||||
|
|
@ -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):
|
- 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
|
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([])
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue