diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index eca440a..ab0b538 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -233,8 +233,14 @@ class BrowserSearchAdapter(SearchAdapter): if self._proxy and self._proxy.enabled: # 포트 확정은 여기서만 한다(유일한 async 지점) — 회전·차단으로 밀린 DB 반영도 함께 flush. ensure = getattr(self._proxy, "ensure_port", None) - if ensure is not None: - await ensure() + if ensure is not None and await ensure() is None: + # 전부 임대/휴식/쿨다운 중 = 지금은 안전하게 크롤할 IP 가 없다. + # 남의 임대를 빌려 쓰거나 프록시 없이 도는 대신 실패한다 — 잡은 백오프 후 재시도하고, + # 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 알림이 따로 운다. + self._note_result(False) + raise AdapterError( + f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도", + source=self.source) kwargs["proxy"] = self._proxy.playwright_proxy() await self._begin_ip_session(self._proxy.current_port) else: diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 159af9f..ad0afe8 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -170,14 +170,16 @@ class DecodoProxy: now = time.monotonic() self._burned = {p: t for p, t in self._burned.items() if t > now} - def _port(self) -> int: + def _port(self) -> int | None: """시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP. 쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선).""" if self._store is not None: - # 임대는 ensure_port(async)가 확정한다. 여기선 확정값을 돌려줄 뿐 — 동기 경로에서 - # DB 를 만지지 않는다. 아직 못 잡았으면 계산식으로 폴백(로그·프리플라이트용). - if self._leased is not None: - return self._leased + # 장부 모드에선 **임대한 포트만** 쓴다(없으면 None). 계산식 폴백을 두면 안 된다 — + # 임대에 실패한 워커가 남이 쥔 포트를 그대로 집어 **같은 IP 에 요청이 겹친다** + # (2026-08-06 실측: 포트 2개·소유자 3인 상황에서 w1 과 w2 가 같은 포트로 동시 요청). + # 하필 풀이 마른 상태 = IP 가 가장 귀할 때 벌어져, 남은 IP 를 두 배 속도로 태운다. + # 이 장부가 막으려던 바로 그 문제이므로, 못 잡았으면 쓰지 않고 실패하는 게 맞다. + return self._leased if self._registry is not None: lease_sec = self.session_minutes * 60 if self._leased is not None and self._registry.lease_expired(self.host, self._leased, self._owner, lease_sec): @@ -207,8 +209,13 @@ class DecodoProxy: """Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용).""" if not self.enabled: return None + port = self._port() + if port is None: + # 장부 모드에서 임대를 못 받은 상태. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가 + # 떠 서버 공인 IP 로 크롤하게 된다(그 IP가 타면 회전으로 복구 불가). 명시적으로 실패한다. + raise RuntimeError(f"{self.host} 가용 프록시 포트 없음 — 임대 없이 크롤할 수 없습니다") return { - "server": f"http://{self.host}:{self._port()}", + "server": f"http://{self.host}:{port}", "username": self.username, "password": self.password, } diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index 0b14f1a..4b89816 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -213,6 +213,26 @@ async def test_sticky_window_expiry_actually_rotates_the_ip(): assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다" +async def test_search_fails_cleanly_when_no_ip_is_available(): + """풀 고갈 시엔 남의 임대를 빌리거나 프록시 없이 도는 대신 **실패**한다 — + 잡이 백오프 후 재시도하는 사이 쿨다운이 풀린다.""" + class _NoPortProxy(_MockProxy): + async def ensure_port(self): return None + + ad = _MockAdapter(_MockPage(["ok"]), proxy=_NoPortProxy()) + + # 실물 _ensure_browser 를 타야 검증이 된다(mock 하니스는 브라우저 기동만 흉내낸다) + async def _real_ensure(): + ensure = getattr(ad._proxy, "ensure_port", None) + if ensure is not None and await ensure() is None: + raise AdapterError(f"{ad.source} 가용 프록시 IP 없음", source=ad.source) + ad._ensure_browser = _real_ensure + + with pytest.raises(AdapterError, match="가용 프록시 IP 없음"): + await ad.search("q") + assert ad._proxy.burned == [], "IP 를 못 받은 것은 IP 잘못이 아니다 — 태우지 않는다" + + async def test_sticky_expiry_rotates_even_when_the_browser_was_idle_closed(): """저트래픽(negodata 수동 트리거)에서는 매 검색이 브라우저 닫힌 채로 들어온다. 예전엔 그 경로에서 만료 검사를 건너뛰어, 예산을 끄면 한 IP 에 영원히 고정됐다(실측).""" diff --git a/lps/tests/test_port_lease.py b/lps/tests/test_port_lease.py index 6030bd1..43763ca 100644 --- a/lps/tests/test_port_lease.py +++ b/lps/tests/test_port_lease.py @@ -64,6 +64,42 @@ async def test_proxy_release_is_safe_without_a_lease(store): await px.release() +# ── 풀 고갈 시 남의 IP 를 빌려 쓰지 않는다 (2026-08-06 회귀) ───────────── +# 임대 실패 시 계산식으로 포트를 고르는 폴백이 있었다. 그러면 임대를 못 받은 워커가 **남이 쥔 +# 포트를 그대로 집어 같은 IP 에 요청이 겹친다**(실측: 포트 2개·소유자 3인 상황에서 재현). +# 하필 풀이 마른 상태 = IP 가 가장 귀할 때 벌어져 남은 IP 를 두 배로 태운다. + +def _proxy_for(store, owner, lo=P_START, hi=P_END): + from services.search.proxy import DecodoProxy + px = DecodoProxy(host=HOST_A, store=store, owner=owner) + px.username, px.password = "u", "p" + px.port_start, px.port_end = lo, hi + px.session_minutes = 10 + return px + + +async def test_exhausted_pool_yields_no_port_instead_of_borrowing(store): + holders = [_proxy_for(store, f"w{i}") for i in range(P_END - P_START + 1)] + for px in holders: # 풀을 전부 소진 + assert await px.ensure_port() is not None + + latecomer = _proxy_for(store, "late") + assert await latecomer.ensure_port() is None, "빈 포트가 없으면 None 이어야 한다" + assert latecomer.current_port is None, "계산식 폴백으로 남의 포트를 집으면 안 된다" + + taken = {px._leased for px in holders} + assert latecomer._leased not in taken + + +async def test_no_proxy_config_when_lease_missing(store): + """임대가 없는데 playwright_proxy() 가 None 을 주면 **프록시 없이** 브라우저가 떠 + 서버 공인 IP 로 크롤한다(그 IP 가 타면 회전으로 복구 불가). 조용히 넘어가면 안 된다.""" + import pytest as _pytest + px = _proxy_for(store, "no-lease") + with _pytest.raises(RuntimeError, match="가용 프록시 포트 없음"): + px.playwright_proxy() + + # ── 배타 임대(프로세스 간) ─────────────────────────────────────────────── async def test_no_two_owners_get_the_same_port(store): """서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""