fix(tradein/proxy_pool): истёкший прокси больше не выдаётся в аренду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m21s

Колонка scrape_proxies.expires_at существовала, но acquire() её не читал
нигде. Когда аренда порта у провайдера заканчивается, он начинает отдавать
407 или просто рвать соединение, а пул продолжал выдавать такой узел. Каждый
запрос через него падал и классифицировался как ban_kind='infra', а 14 таких
из 20 в скользящем окне прерывают прогон добора. То есть один просроченный
порт ронял прогоны, пока человек вручную не заметит и не выключит узел.

Фильтр expires_at IS NULL OR expires_at > now() добавлен в обе ветки отбора —
основную по affinity и fallback. NULL означает «срок неизвестен» и выдаче не
мешает, отсекаются только реально просроченные. Время считает БД, не питон:
иначе рассинхрон часовых поясов.

Пропуск по сроку логируется предупреждением: оператору нужен явный сигнал
«порт просрочен», а не молчаливо сузившийся пул.
This commit is contained in:
bot-backend 2026-08-31 14:38:11 +03:00
parent a256077211
commit a0a2f6ba5e
2 changed files with 130 additions and 0 deletions

View file

@ -292,6 +292,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
остаётся полноценным кандидатом для Яндекса и остальных источников. Бан по чужому
source на выдачу не влияет вообще.
ОБА запроса также отсекают узлы с истёкшей арендой порта у провайдера
(expires_at IS NOT NULL AND expires_at <= now()) иначе после истечения аренды
площадка отвечает 407/рвёт соединение, а пул продолжает выдавать этот узел до
ручного вмешательства. expires_at IS NULL ("срок не отслеживается") выдаче не
мешает. Перед отбором отдельным запросом логируется WARNING по каждому такому
узлу иначе сужение пула из-за истёкшей аренды прошло бы для оператора молча.
Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
@ -299,6 +306,34 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
"""
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
# Просроченные (expires_at в прошлом) enabled-узлы никогда не попадут ни в основной,
# ни в fallback-отбор ниже (оба фильтруют expires_at). Без явного WARNING сужение пула
# прошло бы молча — площадка начинает отдавать 407/рвать соединение по истёкшему
# порту, а pull продолжал бы его выдавать, пока человек не заметит руками.
expired_rows = (
db.execute(
text(
"""
SELECT id
FROM scrape_proxies
WHERE enabled
AND leased_by IS NULL
AND expires_at IS NOT NULL
AND expires_at <= now()
"""
)
)
.mappings()
.all()
)
for expired_row in expired_rows:
logger.warning(
"proxy_pool: proxy id=%d skipped for acquire(provider=%s) — lease expired "
"(expires_at <= now()), not eligible until renewed or disabled",
int(expired_row["id"]),
provider,
)
row = (
db.execute(
text(
@ -309,6 +344,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
AND consecutive_fails < CAST(:max_fails AS integer)
AND provider_affinity IN (:provider, 'any')
AND leased_by IS NULL
AND (expires_at IS NULL OR expires_at > now())
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
@ -344,6 +380,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
AND sp.leased_by IS NULL
AND (sp.expires_at IS NULL OR sp.expires_at > now())
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b

View file

@ -131,6 +131,20 @@ class FakeSession:
sql = str(stmt)
p = params or {}
if "expires_at <= now()" in sql and "FOR UPDATE SKIP LOCKED" not in sql:
# acquire() отдельный WARNING-запрос (#3287): просроченные enabled+свободные
# узлы, вне зависимости от affinity/consecutive_fails — они всё равно не
# попадут ни в основной, ни в fallback-отбор ниже.
expired = [
r
for r in self.rows
if r["enabled"]
and r["leased_by"] is None
and r.get("expires_at") is not None
and r["expires_at"] <= datetime.now(UTC)
]
return _FakeResult([{"id": r["id"]} for r in expired])
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT (primary affinity-scoped or fallback)
max_fails = p["max_fails"]
provider = p["provider"]
@ -139,10 +153,19 @@ class FakeSession:
# NOT EXISTS по scrape_proxy_source_bans, иначе мок реализовывал бы логику
# независимо от проверяемого кода и не отличил бы старый запрос от нового.
filters_bans = "scrape_proxy_source_bans" in sql
# #3287: истёкшая аренда порта не выдаётся — гейтим по подстроке фильтра,
# тот же принцип, что и у filters_bans выше.
filters_expiry = "expires_at" in sql
def _not_banned(row: dict[str, Any]) -> bool:
return not filters_bans or not self._has_active_ban(row["id"], provider)
def _not_expired(row: dict[str, Any]) -> bool:
if not filters_expiry:
return True
exp = row.get("expires_at")
return exp is None or exp > datetime.now(UTC)
if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
cands = [
r
@ -152,6 +175,7 @@ class FakeSession:
and r["provider_affinity"] in (provider, "any")
and r["leased_by"] is None
and _not_banned(r)
and _not_expired(r)
]
else: # fallback: любая affinity, но не последний узел выделенной affinity
# (domclick и т.п. — #2600 review). ВАЖНО: применяем эту фильтрацию,
@ -186,6 +210,7 @@ class FakeSession:
and r["consecutive_fails"] < max_fails
and r["leased_by"] is None
and _not_banned(r)
and _not_expired(r)
and (not protects_last_node or _has_backup(r))
]
# ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id.
@ -469,6 +494,7 @@ def _proxy(
browser_unfit_since: datetime | None = None,
browser_fail_streak: int = 0,
browser_check_at: datetime | None = None,
expires_at: datetime | None = None,
) -> dict[str, Any]:
return {
"id": pid,
@ -489,6 +515,8 @@ def _proxy(
"browser_unfit_since": browser_unfit_since,
"browser_fail_streak": browser_fail_streak,
"browser_check_at": browser_check_at,
# срок аренды порта у провайдера — #3287, None = "не отслеживается"
"expires_at": expires_at,
}
@ -623,6 +651,71 @@ def test_acquire_fallback_backup_must_be_usable_for_its_own_source() -> None:
assert lease is not None and lease.id == 2
# ── acquire × expires_at — просроченная аренда порта не выдаётся (#3287) ───────
def test_acquire_skips_expired_lease() -> None:
"""expires_at в прошлом → узел не выдаётся, даже если enabled/здоров/свободен."""
db = FakeSession(
[_proxy(1, affinity="avito", expires_at=datetime.now(UTC) - timedelta(minutes=1))]
)
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
assert db._by_id(1)["leased_by"] is None # узел не тронут
def test_acquire_issues_proxy_with_null_expires_at() -> None:
"""expires_at IS NULL — "срок не отслеживается", выдаче не мешает (как раньше)."""
db = FakeSession([_proxy(1, affinity="avito", expires_at=None)])
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None and lease.id == 1
def test_acquire_issues_proxy_with_future_expires_at() -> None:
"""expires_at в будущем — аренда ещё жива, узел выдаётся."""
db = FakeSession(
[_proxy(1, affinity="avito", expires_at=datetime.now(UTC) + timedelta(hours=1))]
)
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None and lease.id == 1
def test_acquire_fallback_skips_expired_lease() -> None:
"""Fallback-ветка (чужая affinity) тоже не выдаёт просроченный узел."""
db = FakeSession(
[_proxy(1, affinity="cian", expires_at=datetime.now(UTC) - timedelta(minutes=1))]
)
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
def test_acquire_fallback_prefers_non_expired_over_expired() -> None:
"""Просроченный узел пропускается, живой той же чужой affinity — выдан fallback'ом."""
db = FakeSession(
[
_proxy(1, affinity="cian", expires_at=datetime.now(UTC) - timedelta(minutes=1)),
_proxy(2, affinity="cian", expires_at=datetime.now(UTC) + timedelta(hours=1)),
]
)
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None
assert lease.id == 2
def test_acquire_warns_on_expired_proxy(caplog: pytest.LogCaptureFixture) -> None:
"""Просроченный узел логируется WARNING'ом — оператору нужен явный сигнал (#3287)."""
db = FakeSession(
[
_proxy(1, affinity="avito", expires_at=datetime.now(UTC) - timedelta(minutes=1)),
_proxy(2, affinity="avito", expires_at=datetime.now(UTC) + timedelta(hours=1)),
]
)
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None and lease.id == 2 # живой узел всё равно выдан
assert any(
"expired" in r.message and "id=1" in r.message for r in caplog.records
), "ожидался WARNING про просроченный proxy id=1"
# ── release ──────────────────────────────────────────────────────────────────