fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로 재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물). **① 요청 예산이 사실상 발화하지 않았다 (핵심)** 유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를 0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴 임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은 포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼 드문드문한 실사용 패턴에서만 깨진다. 파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한 '1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라 IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가). → IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session). 세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸). **② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다** 쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로 웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도 9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14). → 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정, 태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계). 같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요). 결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0). **③ 종료가 임대를 반납하지 않았다** close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용 풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를 위해 그대로 둔다). **④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다. sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다. **⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다** 네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다. 확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은 회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다. **⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용). 세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지. 테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로 바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
042e2d3012
commit
377389f495
@ -136,10 +136,15 @@ SELECT avg(ip_request_no), count(*) FROM bot_detection;
|
||||
| `proxy_port` | 사용 포트(=IP 세션). 프록시 미사용이면 NULL |
|
||||
| `requests` | 이 IP로 보낸 요청 수 |
|
||||
| `ok_count` / `blocked_count` | 성공 검색 수 / 차단 감지 수 |
|
||||
| `elapsed_sec` | 세션 지속 시간(초) |
|
||||
| `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(시간창 만료) / `idle`(유휴 정리) / `shutdown`(종료) |
|
||||
| `elapsed_sec` | IP 세션 지속 시간(초) — 브라우저 수명이 아니라 **그 IP 를 쥔 총 시간** |
|
||||
| `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(sticky 수명 만료) / `shutdown`(종료) |
|
||||
| `created_at` | 세션 종료 시각 |
|
||||
|
||||
> **IP 세션 ≠ 브라우저 수명** (2026-08-05 변경). 유휴 정리(120s)는 브라우저만 닫고 같은 IP 로
|
||||
> 돌아오므로 세션이 끝나지 않는다 — 그래서 `idle` 사유는 더 이상 기록되지 않는다.
|
||||
> 예전엔 재기동마다 카운터가 0 으로 리셋돼 **한 IP 를 계속 쓰면서 `requests` 가 항상 1** 로
|
||||
> 남았다(예산이 영영 발화하지 않던 원인). 아래 튜닝 쿼리는 그 시점 이전 데이터엔 쓸 수 없다.
|
||||
|
||||
**예산 튜닝 쿼리** — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정:
|
||||
```sql
|
||||
-- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고,
|
||||
|
||||
@ -101,6 +101,9 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40)
|
||||
| `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) |
|
||||
| `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) |
|
||||
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 |
|
||||
| `[coupang] 미지의 0건 응답(short_html(NB)) — 태우지 않고 회전만 한다` | 알려진 차단 마커가 없다 = 진짜 '검색결과 없음'일 수 있다. 확신이 없어 30분 쿨다운은 걸지 않는다(회전·재시도만) |
|
||||
| **`[coupang] 환경 차단 — 서로 다른 IP 3개가 모두 첫 요청부터...`** | 서킷브레이커 트립. IP 문제가 아니라는 판정이라 **포트를 더 태우지 않는다** — 실행 환경/게이트웨이를 확인할 것 |
|
||||
| `[coupang] 환경 차단 해제 — 검색 성공` | 서킷브레이커 리셋(성공 1회로 자동 해제 — 별도 조치 불필요) |
|
||||
| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP |
|
||||
| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 |
|
||||
| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) |
|
||||
@ -170,6 +173,12 @@ SELECT key, until, reason FROM search_negative ORDER BY created_at DESC;
|
||||
| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) |
|
||||
| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) |
|
||||
| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) |
|
||||
| `fatal_block` | 최근 1h '회전 무효' 차단 수 — 구조적 차단 마커(해외 IP 등) + 서킷브레이커 트립(`env_block`). 1건만 나와도 발화 | (0 초과 시) |
|
||||
|
||||
> ⚠️ **`ip_request_no` 로 원인을 가르는 진단은 2026-08-05 이전 데이터엔 쓸 수 없다.**
|
||||
> 그전에는 유휴 정리마다 카운터가 리셋돼 **실제 사용량과 무관하게 항상 `1`** 로 찍혔다.
|
||||
> "전량 `ip_req#1` → IP 평판 문제" 로 내린 과거 결론(2026-07-28 배포서버 조사 등)은
|
||||
> 그 착시일 수 있으니, 수정 이후 쌓인 데이터로 다시 판단할 것.
|
||||
|
||||
```toml
|
||||
[AlertConfig]
|
||||
@ -206,6 +215,7 @@ LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라
|
||||
| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id |
|
||||
| **네이버 `비정상적인 접근`(2.6KB)** | 해외 IP 로 접근한 것 — **IP 회전으로 회복 불가**. `[DecodoConfig].kr_host` 가 비었거나 오타. 구조적 차단이라 포트를 태우지 않고 즉시 실패하며 `fatal_block` 알림이 뜬다 |
|
||||
| **네이버 `wtm_captcha`(47~63KB)** | IP 평판/세션 — 자동 IP 회전으로 회복. 반복되면 KR 풀 소모 상태(`proxy_port` 테이블) 확인 |
|
||||
| **`환경 차단` 로그 / `fatal_block` 알림** | 서로 다른 IP 3개가 모두 첫 요청부터 막혔다 = IP 로 설명 안 되는 차단. 포트 소각이 자동으로 멈추니 **풀 고갈을 걱정하지 말고** 실행 환경(컨테이너 vs 호스트)·게이트웨이 국가 설정부터 볼 것. 환경이 회복되면 검색 성공 1회로 자동 해제 |
|
||||
| **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 |
|
||||
| **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 |
|
||||
|
||||
@ -226,8 +236,14 @@ LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라
|
||||
실제 x86 리눅스 서버에서는 다를 수 있고, 2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다.
|
||||
그러니 "컨테이너는 안 된다"가 아니라 **"이 맥의 컨테이너에서는 안 된다"** 로 읽어야 한다.
|
||||
|
||||
**2026-08-05 이후 피해 범위**: 서킷브레이커가 붙어서, 이 상황이 와도 **프록시 풀은 안 마른다**.
|
||||
예전엔 웜업만으로 워커당 6포트, 잡 1건당 6포트를 30분 쿨다운에 묶어 잡 16건이면 100포트가
|
||||
고갈됐다(실측). 지금은 판정 근거로 2개를 쓴 뒤 소각이 멈추고 `fatal_block` 알림이 곧바로 뜬다.
|
||||
즉 **"조용히 풀만 태우다 멈추는" 실패가 "즉시 알리고 멈추는" 실패로 바뀌었다** — 원인 자체는
|
||||
아직 미해결이므로 아래 순서로 확인한다.
|
||||
|
||||
**배포 시 확인 순서**
|
||||
1. `docker logs lps-worker | grep warmup` — 기동 직후 소스별 통과 여부가 찍힌다
|
||||
1. `docker logs lps-worker | grep -E "warmup|환경 차단"` — 기동 직후 소스별 통과 여부가 찍힌다
|
||||
2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지)
|
||||
3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다
|
||||
(compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다)
|
||||
|
||||
@ -53,6 +53,11 @@ _PROXY_ERR_MARKERS = (
|
||||
)
|
||||
|
||||
|
||||
# 서킷브레이커가 트립했을 때 남기는 감지 마커. HTML 에서 나오는 마커가 아니라 **연속 패턴**으로
|
||||
# 판정한 것이라, 알림(worker_main._fatal_markers)이 이 값을 '회전 무효' 계열로 함께 센다.
|
||||
ENV_BLOCK_MARKER = "env_block"
|
||||
|
||||
|
||||
def is_proxy_error(msg: str) -> bool:
|
||||
"""예외 메시지가 프록시 전송 실패(포트/IP 사망·407)인지(순수 함수, 단위 테스트 가능).
|
||||
True 면 사이트 차단이 아니라 프록시 문제 → 다른 IP 로 회전하면 회복 가능."""
|
||||
@ -69,6 +74,13 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
# 태우면 안 되는 이유: 원인이 IP 가 아닌데 100개를 순서대로 쿨다운시키고, 같은 게이트웨이를
|
||||
# 쓰는 다른 소스(쿠팡)의 풀까지 말려버린다.
|
||||
fatal_block_markers: tuple = ()
|
||||
# 환경 차단 판정 임계 — 서로 다른 IP 가 연속으로 **첫 요청부터**(ip_req#1) 막힌 횟수.
|
||||
# 이 조합은 IP 로 설명되지 않는다: IP 평판 문제라면 몇 개는 통과해야 하고, 요청을 너무
|
||||
# 많이 쐈다면 #1 이 아니라 뒤쪽에서 막힌다. 남는 원인은 환경 쪽이다(컨테이너·게이트웨이
|
||||
# 국가/대역 전체 차단 등) — 회전해도 못 고친다.
|
||||
# 임계 없이 계속 태우면 잡 16건에 100포트가 30분 쿨다운으로 묶여 같은 게이트웨이를 쓰는
|
||||
# 다른 소스까지 말린다(2026-08-05 실측). 실제 장부에도 9분간 11포트 연속 소각 이력이 있다.
|
||||
env_block_threshold: int = 3
|
||||
min_result_html: int = 10000
|
||||
ready_selector: str = "body"
|
||||
ready_timeout_ms: int = 20000
|
||||
@ -97,16 +109,30 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
|
||||
self._pw = None
|
||||
self._ctx = None
|
||||
self._launched_at = 0.0
|
||||
self._ip_requests = 0 # 현재 브라우저(IP)로 보낸 요청 수(재기동 시 리셋)
|
||||
self._launched_at = 0.0 # 현재 브라우저 컨텍스트 기동 시각(시간창 재기동 판정용)
|
||||
# ── IP(포트) 세션 상태 — 브라우저 수명과 **분리**한다 ────────────────────
|
||||
# 예산은 '이 IP 로 몇 번 요청했나'지 '이 브라우저로 몇 번'이 아니다. 예전엔 아래 값들을
|
||||
# 브라우저 재기동 때마다 0 으로 되돌렸는데, 유휴 정리(close_if_idle)는 브라우저만 닫고
|
||||
# 임대는 그대로 두므로 **같은 IP 를 계속 쓰면서 카운터만 리셋**됐다. 게다가 ensure_port
|
||||
# 는 재기동마다 임대를 renew 해 만료를 뒤로 민다 — 검색이 유휴 임계(120s)보다 뜸하고
|
||||
# 임대(10분)보다 잦으면 한 IP 에 영원히 고정된다(2026-08-05 실측: 6회 검색이 전부 같은
|
||||
# 포트·ip_req#1). 그러면 예산이 영영 발화하지 않고, bot_detection.ip_request_no 도 항상
|
||||
# 1 로 찍혀 '몇 번째 요청에서 막혔나'(IP 평판이냐 예산이냐) 진단이 통째로 무너진다.
|
||||
# → 이 값들은 **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
|
||||
self._session_started_at = 0.0 # 현재 IP 세션 시작 시각(monotonic)
|
||||
self._ip_requests = 0 # 현재 IP 로 보낸 요청 수
|
||||
self._current_port = None
|
||||
self._force_recycle = False
|
||||
self._lock = asyncio.Lock()
|
||||
self._ok = 0
|
||||
self._blocked = 0
|
||||
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용, 재기동 시 리셋)
|
||||
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용)
|
||||
self._sess_blocked = 0
|
||||
self._end_reason = None # 이번 세션이 끝나는 이유(budget/block/proxy_error/window/idle/shutdown)
|
||||
# 환경 차단 서킷브레이커 상태 — '첫 요청부터 막힌' 서로 다른 포트들. 성공 1회로 초기화된다
|
||||
# (타이머가 없어도 환경이 회복되면 스스로 풀린다).
|
||||
self._fresh_ip_blocks: list = []
|
||||
self._env_blocked = False
|
||||
self._end_reason = None # 이번 IP 세션이 끝나는 이유(budget/block/proxy_error/window/shutdown)
|
||||
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
|
||||
self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백
|
||||
self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적)
|
||||
@ -155,15 +181,30 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
return False
|
||||
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60
|
||||
|
||||
async def _recycle_if_due(self) -> bool:
|
||||
"""기존 컨텍스트를 계속 쓸 수 있으면 False, 닫고 새로 띄워야 하면 True.
|
||||
|
||||
재기동 사유는 두 가지고 처리가 다르다:
|
||||
force_recycle 이미 회전이 예약된 상태(예산·차단·프록시오류) — 여기서 또 회전하지 않는다
|
||||
시간창 만료 제공자 쪽 sticky 세션도 끝났다 = 같은 포트라도 IP 가 바뀐다
|
||||
시간창 만료인데 임대를 쥔 채 브라우저만 다시 띄우면 ensure_port 의 renew 가 **같은 포트를
|
||||
계속 붙잡아** 회전이 일어나지 않는다(로그만 'IP 회전'이라 찍혔다). 명시적으로 놓아준다 —
|
||||
그래야 LRU 가 다음 IP 를 내주고 요청 카운터도 새 IP 기준으로 다시 센다.
|
||||
"""
|
||||
if self._ctx is None:
|
||||
return True
|
||||
if not self._recycle_due():
|
||||
return False
|
||||
if not self._force_recycle:
|
||||
self._rotate_ip(f"sticky {self._proxy.session_minutes}분 만료 — 새 IP",
|
||||
kind="window", warn=False)
|
||||
LOG.d(f"[{self.source}] 브라우저 재기동")
|
||||
await self._close_ctx()
|
||||
return True
|
||||
|
||||
async def _ensure_browser(self):
|
||||
if self._ctx is not None:
|
||||
if self._recycle_due():
|
||||
LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)")
|
||||
if self._end_reason is None: # force 가 아닌 시간창 만료 재기동
|
||||
self._end_reason = "window"
|
||||
await self._close_ctx()
|
||||
else:
|
||||
return
|
||||
if not await self._recycle_if_due():
|
||||
return
|
||||
if self._pw is None:
|
||||
self._pw = await async_playwright().start()
|
||||
kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True)
|
||||
@ -180,15 +221,29 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
if ensure is not None:
|
||||
await ensure()
|
||||
kwargs["proxy"] = self._proxy.playwright_proxy()
|
||||
self._current_port = self._proxy.current_port
|
||||
await self._begin_ip_session(self._proxy.current_port)
|
||||
else:
|
||||
await self._begin_ip_session(None) # 프록시 없음 = IP 하나짜리 세션
|
||||
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
|
||||
if self._block_resources:
|
||||
await self._ctx.route("**/*", self._route)
|
||||
self._launched_at = time.monotonic()
|
||||
self._force_recycle = False
|
||||
|
||||
async def _begin_ip_session(self, port):
|
||||
"""IP 세션의 경계를 관리한다 — **포트가 실제로 바뀔 때만** 이전 세션을 마감하고 카운터를 리셋.
|
||||
|
||||
같은 포트로 브라우저만 재기동한 경우(유휴 정리 후 복귀 등)는 여전히 같은 IP 이므로
|
||||
요청 수·성공/차단 누적을 그대로 이어간다. 이게 예산(_budget_reached)과
|
||||
bot_detection.ip_request_no 를 실제 IP 사용량에 맞추는 유일한 지점이다.
|
||||
"""
|
||||
if self._session_started_at and port == self._current_port:
|
||||
return # 같은 IP 계속 사용 — 카운터 유지
|
||||
await self._record_session_end() # 이전 IP 세션 마감(요청이 있었을 때만 기록)
|
||||
self._current_port = port
|
||||
self._session_started_at = time.monotonic()
|
||||
self._ip_requests = 0
|
||||
self._sess_ok = self._sess_blocked = 0
|
||||
self._end_reason = None
|
||||
self._force_recycle = False
|
||||
|
||||
async def _close_ctx(self):
|
||||
# 밀린 반납·차단을 먼저 DB 에 반영한다 — 여기서 흘리지 않으면 종료 시 유실돼
|
||||
@ -201,7 +256,9 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
LOG.d(f"[{self.source}] 포트 상태 flush 실패(무시): {type(ex).__name__}")
|
||||
self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착
|
||||
if self._ctx is not None:
|
||||
await self._record_session_end()
|
||||
# 세션 종료 기록은 여기서 하지 않는다 — 브라우저를 닫는 것과 IP 세션이 끝나는 것은
|
||||
# 다른 사건이다(유휴 정리는 브라우저만 닫고 같은 IP 로 돌아온다).
|
||||
# 기록은 포트가 바뀌는 _begin_ip_session 과 최종 close() 가 담당한다.
|
||||
try:
|
||||
await self._ctx.close()
|
||||
finally:
|
||||
@ -209,15 +266,21 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
|
||||
async def _record_session_end(self):
|
||||
"""IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터.
|
||||
요청이 없던 세션(유휴 정리 등)은 노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
|
||||
요청이 없던 세션은 노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
|
||||
if self._on_session_end is None or self._ip_requests == 0:
|
||||
self._end_reason = None
|
||||
return
|
||||
event = {"source": self.source, "proxy_port": self._current_port,
|
||||
"requests": self._ip_requests, "ok_count": self._sess_ok, "blocked_count": self._sess_blocked,
|
||||
"elapsed_sec": int(time.monotonic() - self._launched_at),
|
||||
# 브라우저 기동 시각이 아니라 **IP 세션** 시작 시각 기준 — 유휴 정리로 브라우저가
|
||||
# 한 번 닫혀도 그 IP 를 쥔 총 시간이 나온다(안전 상한을 시간축으로도 보기 위해).
|
||||
"elapsed_sec": int(time.monotonic() - (self._session_started_at or time.monotonic())),
|
||||
"end_reason": self._end_reason or "window"}
|
||||
self._end_reason = None
|
||||
# 마감한 세션은 비운다 — 멱등성 확보. 안 비우면 close() 가 두 번 불릴 때(종료 경로가
|
||||
# 겹치는 경우) 같은 IP 세션이 두 건으로 기록돼 예산 튜닝 통계가 부풀려진다.
|
||||
self._ip_requests = 0
|
||||
self._sess_ok = self._sess_blocked = 0
|
||||
try:
|
||||
await self._on_session_end(event)
|
||||
except Exception as ex:
|
||||
@ -250,6 +313,28 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
"""현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False."""
|
||||
return self.uses_proxy and self._ip_budget > 0 and self._ip_requests >= self._ip_budget
|
||||
|
||||
def _note_fresh_ip_block(self) -> bool:
|
||||
"""'새 IP 의 첫 요청부터 차단' 을 **서로 다른 포트** 기준으로 세고, 임계 도달 시 트립한다.
|
||||
|
||||
같은 포트를 두 번 세지 않는 게 핵심이다 — 한 IP 가 반복해 막히는 건 그냥 나쁜 IP 지
|
||||
환경 문제가 아니다. 트립 후에는 더 세지 않는다(목록이 무한히 자라지 않게).
|
||||
"""
|
||||
if self._env_blocked:
|
||||
return True
|
||||
if not self.uses_proxy or self._ip_requests != 1:
|
||||
return False # 뒤쪽 요청에서 막힌 건 '이 IP 를 많이 썼다' 신호
|
||||
if self._current_port not in self._fresh_ip_blocks:
|
||||
self._fresh_ip_blocks.append(self._current_port)
|
||||
self._env_blocked = len(self._fresh_ip_blocks) >= self.env_block_threshold
|
||||
return self._env_blocked
|
||||
|
||||
def _clear_env_block(self):
|
||||
"""성공 1회 = 환경이 멀쩡하다는 증거. 별도 타이머 없이 여기서 스스로 풀린다."""
|
||||
if self._env_blocked:
|
||||
LOG.i(f"[{self.source}] 환경 차단 해제 — 검색 성공(서킷브레이커 리셋)")
|
||||
self._fresh_ip_blocks.clear()
|
||||
self._env_blocked = False
|
||||
|
||||
def _rotate_ip(self, reason: str, kind: str = "rotate", warn: bool = True):
|
||||
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.
|
||||
kind 는 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망)."""
|
||||
@ -297,6 +382,7 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
if products:
|
||||
self._ok += 1
|
||||
self._sess_ok += 1
|
||||
self._clear_env_block()
|
||||
self._note_result(True)
|
||||
LOG.d(f"[{self.source}] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})")
|
||||
return products[:limit]
|
||||
@ -316,7 +402,30 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
|
||||
raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요",
|
||||
source=self.source, blocked=True, fatal=True)
|
||||
if self.uses_proxy:
|
||||
# 확신도에 따라 대응을 가른다.
|
||||
# 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다
|
||||
# short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과
|
||||
# 없음' 페이지일 수 있는데(네이버 무결과 페이지 크기는
|
||||
# 아직 실측된 적이 없다) 확신 없이 태우면 정상 응답에
|
||||
# IP 를 30분 묶고, 서킷브레이커까지 오작동해 소스를 멈춘다.
|
||||
# → 미지의 경우엔 회전·재시도까지만 한다(짧고 되돌릴 수 있는 대응).
|
||||
# 판단 근거는 bot_detection 에 marker='short_html(NB)' 로 쌓이므로,
|
||||
# 나중에 그 분포를 보고 임계(min_result_html)를 실측으로 정할 수 있다.
|
||||
known = marker in self.block_markers
|
||||
if not known:
|
||||
LOG.d(f"[{self.source}] 미지의 0건 응답({marker}) — 태우지 않고 회전만 한다")
|
||||
if known and self._note_fresh_ip_block():
|
||||
# 서로 다른 IP 가 연속으로 첫 요청부터 막혔다 = IP 문제가 아니다.
|
||||
# **태우지 않고** 포기한다. 태워봐야 원인은 그대로인데 풀만 마른다.
|
||||
# IP 는 바꿔둔다(쿨다운 없이 반납) — 죽은 IP 를 계속 쥐고 있지 않도록.
|
||||
await self._report_env_block(query, marker)
|
||||
self._rotate_ip("환경 차단 — 태우지 않고 IP 만 교체", kind="block", warn=False)
|
||||
self._note_result(False)
|
||||
raise AdapterError(
|
||||
f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, "
|
||||
f"최근 마커={marker}) — IP 회전으로 회복 불가",
|
||||
source=self.source, blocked=True, fatal=True)
|
||||
if known and self.uses_proxy:
|
||||
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
|
||||
|
||||
if blocked and self.uses_proxy and block_retries > 0:
|
||||
@ -330,7 +439,8 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked)
|
||||
|
||||
async def _report_detection(self, query: str, marker: str, html_len: int):
|
||||
elapsed = int(time.monotonic() - self._launched_at)
|
||||
# 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다.
|
||||
elapsed = int(time.monotonic() - (self._session_started_at or time.monotonic()))
|
||||
LOG.w(f"[{self.source}][BOT-DETECTED] ip_req#{self._ip_requests} port={self._current_port} "
|
||||
f"elapsed={elapsed}s headless={self._headless} marker={marker!r} query={query!r} html_len={html_len}")
|
||||
if self._on_detect is not None:
|
||||
@ -342,6 +452,19 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[{self.source}] 감지 기록 실패(무시): {ex}")
|
||||
|
||||
async def _report_env_block(self, query: str, marker: str):
|
||||
"""서킷브레이커가 막은 검색을 감지 이력으로 남긴다 — 알림이 이 마커를 세서 즉시 알린다.
|
||||
|
||||
트립 1회만이 아니라 **막힌 검색마다** 남긴다. 알림은 '최근 1시간 fatal 마커 수'로 판정하므로,
|
||||
한 번만 기록하면 문제가 그대로인데도 1시간 뒤 '해소' 알림이 나간다.
|
||||
"""
|
||||
LOG.e_no_callstack(
|
||||
f"[{self.source}] 환경 차단 — 서로 다른 IP {len(self._fresh_ip_blocks)}개가 모두 "
|
||||
f"첫 요청부터 막혔습니다(마커={marker!r}). IP 회전으로는 회복되지 않습니다. "
|
||||
f"실행 환경(컨테이너 vs 호스트)과 게이트웨이 설정을 확인하세요. "
|
||||
f"— 포트는 더 태우지 않습니다")
|
||||
await self._report_detection(query, ENV_BLOCK_MARKER, 0)
|
||||
|
||||
async def health(self) -> AdapterHealth:
|
||||
total = self._ok + self._blocked
|
||||
rate = (self._ok / total) if total else 0.0
|
||||
@ -350,22 +473,33 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
|
||||
async def close_if_idle(self, idle_sec: float):
|
||||
"""일정 시간 검색이 없으면 브라우저 컨텍스트를 정리(메모리 회수). playwright 는 유지 —
|
||||
다음 검색 때 재기동한다. cf_clearance 등 쿠키는 user_data_dir 에 남아 재기동해도 웜 유지."""
|
||||
다음 검색 때 재기동한다. cf_clearance 등 쿠키는 user_data_dir 에 남아 재기동해도 웜 유지.
|
||||
|
||||
**IP 세션은 끝나지 않는다** — 임대는 그대로 두고 같은 IP 로 돌아오므로, 요청 예산 카운터도
|
||||
이어진다(예전엔 여기서 리셋돼 예산이 발화하지 않았다)."""
|
||||
if self._ctx is None or self._lock.locked(): # 검색 중이면 건너뜀
|
||||
return
|
||||
if time.monotonic() - self._last_used < idle_sec:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec:
|
||||
LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리(다음 검색 때 재기동)")
|
||||
if self._end_reason is None:
|
||||
self._end_reason = "idle"
|
||||
LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리"
|
||||
f"(IP 세션 유지 · ip_req#{self._ip_requests})")
|
||||
await self._close_ctx()
|
||||
|
||||
async def close(self):
|
||||
if self._end_reason is None:
|
||||
self._end_reason = "shutdown"
|
||||
await self._close_ctx()
|
||||
await self._record_session_end() # 프로세스 종료 = IP 세션도 여기서 끝난다
|
||||
# 쥐고 있던 포트를 돌려준다 — 안 그러면 재시작해도 임대 만료(최대 sticky 수명)까지
|
||||
# 그 IP 를 아무도 못 쓴다. 정리 실패가 종료를 막지는 않는다(임대는 만료로도 회수된다).
|
||||
release = getattr(self._proxy, "release", None)
|
||||
if release is not None:
|
||||
try:
|
||||
await release()
|
||||
except Exception as ex:
|
||||
LOG.d(f"[{self.source}] 포트 반납 실패(무시 — 임대 만료로 회수됨): {type(ex).__name__}")
|
||||
if self._pw is not None:
|
||||
await self._pw.stop()
|
||||
self._pw = None
|
||||
|
||||
@ -124,6 +124,23 @@ class DecodoProxy:
|
||||
LOG.w(f"[port] {self.host} 가용 포트 없음 — 전부 임대/휴식/쿨다운 중({self._owner})")
|
||||
return self._leased
|
||||
|
||||
async def release(self):
|
||||
"""쥐고 있는 임대를 **즉시 반납**한다(프로세스 종료 시).
|
||||
|
||||
이걸 안 하면 워커를 재시작해도 그 포트는 leased_until(sticky 수명, 기본 10분)까지 남의
|
||||
것으로 남아 아무도 못 쓴다 — 재시작이 잦을수록 가용 풀이 계속 줄어든다.
|
||||
**유휴 정리에서는 부르지 않는다**: 브라우저만 닫고 같은 IP 로 돌아와야 웜 쿠키
|
||||
(cf_clearance 등)가 살고 요청 예산도 이어진다.
|
||||
"""
|
||||
await self.flush()
|
||||
if self._leased is None:
|
||||
return
|
||||
port, self._leased = self._leased, None
|
||||
if self._store is not None:
|
||||
await self._store.release(self.host, port, self._owner)
|
||||
elif self._registry is not None:
|
||||
self._registry.release(self.host, port, self._owner)
|
||||
|
||||
async def flush(self):
|
||||
"""밀린 반납·차단만 반영(검색 종료·셧다운 시). 포트를 새로 잡지는 않는다."""
|
||||
if self._store is None:
|
||||
@ -137,7 +154,13 @@ class DecodoProxy:
|
||||
await self._store.burn(self.host, port, cd, owner=self._owner, reason="block")
|
||||
|
||||
def available_ports(self) -> int:
|
||||
"""쿨다운 중이 아닌 포트 수(관측·알림용)."""
|
||||
"""쿨다운 중이 아닌 포트 수(관측용) — **인메모리 모드 전용**.
|
||||
|
||||
DB 장부(store) 모드에서는 이 값을 믿으면 안 된다: 차단은 _pending_burn 으로만 쌓였다가
|
||||
DB 로 나가므로 로컬 _burned 는 늘 비어 있고, 다른 프로세스가 태운 포트도 보이지 않아
|
||||
**항상 풀 크기(전량 가용)를 돌려준다**. 장부 모드의 실제 가용량은 비동기 조회라
|
||||
여기서 낼 수 없다 — PortLeaseStore.snapshot() 을 쓴다(worker_main._port_pool_status).
|
||||
"""
|
||||
if self._registry is not None:
|
||||
return self._registry.available(self.host)
|
||||
self._prune_burned()
|
||||
|
||||
@ -9,10 +9,11 @@ test_coupang_parser / test_openmarket_parser 가 저장 HTML 로 커버.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from services.search.browser_base import BrowserSearchAdapter
|
||||
from services.search.browser_base import BrowserSearchAdapter, ENV_BLOCK_MARKER
|
||||
from services.search.contract import NormalizedProduct, AdapterError
|
||||
from services.search.rate_limiter import RateLimiter
|
||||
|
||||
@ -55,7 +56,9 @@ class _MockProxy:
|
||||
def mark_burned(self, port, cooldown_sec=None): self.burned.append(port)
|
||||
def playwright_proxy(self): return None
|
||||
@property
|
||||
def current_port(self): return 10001
|
||||
def current_port(self):
|
||||
# 회전하면 실제로 포트가 바뀐다 — 고정값이면 '같은 IP 를 계속 쓰는' 버그를 테스트가 못 본다.
|
||||
return 10001 + self.rotations
|
||||
|
||||
|
||||
class _MockAdapter(BrowserSearchAdapter):
|
||||
@ -68,11 +71,14 @@ class _MockAdapter(BrowserSearchAdapter):
|
||||
super().__init__(rate_limiter=RateLimiter(0, 0), **kw)
|
||||
|
||||
async def _ensure_browser(self):
|
||||
if self._ctx is None or self._force_recycle: # 회전(force_recycle) 시 재기동 흉내
|
||||
self._ctx = _MockCtx(self._page)
|
||||
self._force_recycle = False
|
||||
self._ip_requests = 0
|
||||
self._current_port = self._proxy.current_port if self._proxy else None # 실제 _ensure_browser 와 동일
|
||||
# 재기동 판단(_recycle_if_due)·IP 세션 경계(_begin_ip_session)는 **실물 코드를 그대로 탄다**
|
||||
# — 브라우저 기동만 흉내낸다. 그래야 이 하니스가 진짜 동작을 검증한다.
|
||||
if not await self._recycle_if_due():
|
||||
return
|
||||
self._ctx = _MockCtx(self._page)
|
||||
await self._begin_ip_session(self._proxy.current_port if self._proxy else None)
|
||||
self._launched_at = time.monotonic()
|
||||
self._force_recycle = False
|
||||
|
||||
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
|
||||
async def _wait_ready(self, page): pass
|
||||
@ -114,11 +120,183 @@ async def test_non_proxy_goto_error_raises_no_rotation():
|
||||
|
||||
async def test_persistent_block_exhausts_and_raises_blocked():
|
||||
# 차단 2회 연속(max_block_retries=1) → 재시도 회전 1회 + 소진 후 '다음 검색용' 회전 1회 → blocked=True 로 실패
|
||||
ad = _ad(["x", "x"])
|
||||
ad = _ad(["<html>BOTBLOCK</html>", "<html>BOTBLOCK</html>"])
|
||||
with pytest.raises(AdapterError) as ei:
|
||||
await ad.search("q")
|
||||
assert ei.value.blocked is True and ad._proxy.rotations == 2
|
||||
assert ad._proxy.burned == [10001, 10001] # 차단마다 해당 포트 쿨다운 격리
|
||||
assert ad._proxy.burned == [10001, 10002] # 차단마다 **그때 쓰던** 포트를 쿨다운 격리
|
||||
|
||||
|
||||
async def test_unknown_short_html_rotates_but_does_not_burn():
|
||||
"""'0건인데 페이지가 짧다'는 정황일 뿐 — 진짜 검색결과 없음일 수 있다.
|
||||
확신 없이 태우면 정상 응답에 IP 를 30분 묶는다. 회전·재시도까지만 한다."""
|
||||
ad = _ad(["x", "x"])
|
||||
with pytest.raises(AdapterError) as ei:
|
||||
await ad.search("q")
|
||||
assert ei.value.blocked is True, "회복 시도는 해야 하므로 blocked 로 알린다"
|
||||
assert ad._proxy.rotations == 2, "IP 는 바꿔 재시도한다(비용이 짧고 되돌릴 수 있다)"
|
||||
assert ad._proxy.burned == [], "확신 없는 판정으로 30분 쿨다운을 걸지 않는다"
|
||||
|
||||
|
||||
async def test_unknown_short_html_does_not_trip_the_env_breaker():
|
||||
"""무결과 질의가 연달아 나와도 '환경 차단'으로 오판해 소스를 멈추면 안 된다."""
|
||||
ad = _MockAdapter(_MockPage(["x"] * 8), proxy=_MockProxy(), max_block_retries=1)
|
||||
for _ in range(3):
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
|
||||
|
||||
|
||||
# ── IP 세션 ≠ 브라우저 수명 (2026-08-05 회귀) ────────────────────────────
|
||||
# 유휴 정리(close_if_idle)는 브라우저만 닫고 IP 임대는 그대로 둔다. 예전엔 재기동 때마다
|
||||
# 요청 카운터를 0 으로 되돌려, 검색이 유휴 임계보다 뜸하면 **한 IP 를 무한히 쓰면서 예산이
|
||||
# 영영 발화하지 않았다**(실측: 6회 검색이 전부 같은 포트·ip_req#1). ip_request_no 도 항상 1 로
|
||||
# 찍혀 '몇 번째 요청에서 막혔나' 진단까지 무너졌다.
|
||||
|
||||
async def _idle_cleanup(ad):
|
||||
"""run_browser_reaper 가 하는 일 — 유휴 임계를 넘겼다고 보고 브라우저만 닫는다."""
|
||||
ad._last_used -= 10_000
|
||||
await ad.close_if_idle(120)
|
||||
|
||||
|
||||
async def test_idle_cleanup_keeps_the_ip_session_counter():
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy())
|
||||
seen = []
|
||||
for _ in range(3):
|
||||
await ad.search("q")
|
||||
seen.append((ad._current_port, ad._ip_requests))
|
||||
await _idle_cleanup(ad)
|
||||
assert seen == [(10001, 1), (10001, 2), (10001, 3)], \
|
||||
"같은 IP 로 돌아왔으면 요청 수가 이어져야 한다(브라우저 재기동은 IP 교체가 아니다)"
|
||||
|
||||
|
||||
async def test_budget_still_fires_when_idle_cleanup_happens_between_searches():
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 4), proxy=_MockProxy(), ip_request_budget=3)
|
||||
for _ in range(4):
|
||||
await ad.search("q")
|
||||
await _idle_cleanup(ad)
|
||||
assert ad._proxy.rotations == 1, "예산 3회를 채웠으면 4번째 검색 전에 선제 회전해야 한다"
|
||||
assert ad._proxy.kinds == ["budget"]
|
||||
assert (ad._current_port, ad._ip_requests) == (10002, 1), "회전 뒤에는 새 IP 로 카운터가 리셋된다"
|
||||
|
||||
|
||||
async def test_session_is_recorded_once_per_ip_not_per_browser_restart():
|
||||
events = []
|
||||
|
||||
async def on_end(e):
|
||||
events.append(e)
|
||||
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 4), proxy=_MockProxy(),
|
||||
ip_request_budget=3, on_session_end=on_end)
|
||||
for _ in range(4):
|
||||
await ad.search("q")
|
||||
await _idle_cleanup(ad)
|
||||
assert len(events) == 1, "유휴 정리는 IP 세션을 끝내지 않는다 — 포트가 바뀔 때만 1건"
|
||||
assert events[0]["requests"] == 3 and events[0]["end_reason"] == "budget"
|
||||
|
||||
await ad.close() # 종료 시 진행 중이던 IP 세션도 마감된다
|
||||
assert [e["end_reason"] for e in events] == ["budget", "shutdown"]
|
||||
|
||||
await ad.close() # 종료 경로가 겹쳐 두 번 불려도 같은 세션을 또 기록하지 않는다
|
||||
assert [e["end_reason"] for e in events] == ["budget", "shutdown"]
|
||||
|
||||
|
||||
async def test_sticky_window_expiry_actually_rotates_the_ip():
|
||||
"""시간창 만료 = 제공자 sticky 세션도 끝났다 → 같은 포트를 계속 붙잡으면 안 된다.
|
||||
예전엔 브라우저만 재기동하고 임대를 renew 해 IP 가 그대로였다(로그만 'IP 회전')."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy())
|
||||
await ad.search("q")
|
||||
assert (ad._current_port, ad._proxy.rotations) == (10001, 0)
|
||||
|
||||
ad._launched_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
|
||||
await ad.search("q")
|
||||
assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다"
|
||||
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다"
|
||||
|
||||
|
||||
async def test_forced_rotation_does_not_double_rotate_on_window():
|
||||
"""이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1)
|
||||
await ad.search("q") # 예산 1회 → 다음 검색 전에 선제 회전 예약
|
||||
ad._launched_at -= ad._proxy.session_minutes * 60 + 1
|
||||
await ad.search("q")
|
||||
assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다"
|
||||
assert ad._proxy.rotations == 1
|
||||
|
||||
|
||||
# ── 환경 차단 서킷브레이커 (2026-08-05) ─────────────────────────────────
|
||||
# 서로 다른 IP 가 연속으로 첫 요청부터 막히면 IP 문제가 아니다 → 태우기를 멈춘다.
|
||||
# 없을 때는 잡 1건이 포트 6개를 30분 쿨다운에 묶어, 16건이면 풀 100개가 고갈됐다(실측).
|
||||
|
||||
async def test_env_block_trips_and_stops_burning_ports():
|
||||
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 8), proxy=_MockProxy(), max_block_retries=1)
|
||||
# 검색 1: 서로 다른 IP 2개가 첫 요청부터 차단(임계 3 미달) → 기존대로 태우고 포기
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert ad._proxy.burned == [10001, 10002] and ad._env_blocked is False
|
||||
|
||||
# 검색 2: 3번째 IP 도 첫 요청부터 차단 → 트립. 이 포트는 태우지 않는다
|
||||
with pytest.raises(AdapterError) as ei:
|
||||
await ad.search("q")
|
||||
assert ad._env_blocked is True
|
||||
assert ei.value.fatal is True, "회전으로 회복 불가 → 호출부가 설정을 의심하도록 fatal"
|
||||
assert ad._proxy.burned == [10001, 10002], "트립한 IP 는 태우지 않는다"
|
||||
|
||||
# 검색 3 이후: 계속 막혀도 포트를 더 태우지 않는다
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert ad._proxy.burned == [10001, 10002]
|
||||
|
||||
|
||||
async def test_env_block_records_marker_every_time_it_blocks():
|
||||
"""알림은 '최근 1h fatal 마커 수'로 판정한다 — 트립 1회만 기록하면 문제가 그대로인데
|
||||
1시간 뒤 '해소' 알림이 나간다. 그래서 막힌 검색마다 남긴다."""
|
||||
events = []
|
||||
|
||||
async def on_detect(ev):
|
||||
events.append(ev)
|
||||
|
||||
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 6), proxy=_MockProxy(),
|
||||
max_block_retries=1, on_detect=on_detect)
|
||||
for _ in range(3): # 검색1=미트립, 검색2=트립, 검색3=트립 유지
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert [e["marker"] for e in events].count(ENV_BLOCK_MARKER) == 2
|
||||
|
||||
|
||||
async def test_repeated_block_on_one_ip_is_not_an_env_block():
|
||||
"""같은 IP 가 반복해 막히는 건 그냥 나쁜 IP 다 — 환경 차단으로 오판하면 안 된다."""
|
||||
class _StickyProxy(_MockProxy):
|
||||
@property
|
||||
def current_port(self): return 10001 # 회전해도 같은 포트(=같은 IP)
|
||||
|
||||
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 8), proxy=_StickyProxy(), max_block_retries=1)
|
||||
for _ in range(3):
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert ad._env_blocked is False and ad._fresh_ip_blocks == [10001]
|
||||
|
||||
|
||||
async def test_block_after_several_requests_is_not_an_env_block():
|
||||
"""뒤쪽 요청(ip_req#2+)에서 막힌 건 '이 IP 를 많이 썼다'는 뜻 — 예산 문제지 환경 문제가 아니다."""
|
||||
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>", "<html>BOTBLOCK</html>", "<html>BOTBLOCK</html>"]),
|
||||
proxy=_MockProxy(), max_block_retries=0)
|
||||
await ad.search("q") # ip_req#1 성공
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q") # ip_req#2 에서 차단
|
||||
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
|
||||
|
||||
|
||||
async def test_success_resets_the_breaker():
|
||||
# 검색1 = 차단 2회(재시도 포함), 검색2 = 차단 1회로 트립 → 검색3 에서 회복
|
||||
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 3 + ["<PRODUCT>ok</PRODUCT>"]),
|
||||
proxy=_MockProxy(), max_block_retries=1)
|
||||
for _ in range(2):
|
||||
with pytest.raises(AdapterError):
|
||||
await ad.search("q")
|
||||
assert ad._env_blocked is True
|
||||
await ad.search("q") # 환경 회복
|
||||
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
|
||||
|
||||
|
||||
# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ──────
|
||||
|
||||
@ -36,6 +36,34 @@ async def _exec(sql: str, params: dict | None = None):
|
||||
await DB_SESSION_MNG.end_session(DBType.MAIN.value, DBWRType.DB_WRITE.value)
|
||||
|
||||
|
||||
# ── 종료 시 반납 (2026-08-05) ────────────────────────────────────────────
|
||||
async def test_proxy_release_returns_the_port_immediately(store):
|
||||
"""워커 종료가 임대를 놓아야 한다 — 안 놓으면 재시작해도 최대 sticky 수명(10분)까지 못 쓴다."""
|
||||
from services.search.proxy import DecodoProxy
|
||||
|
||||
px = DecodoProxy(host=HOST_A, store=store, owner="shutdown-w0")
|
||||
px.username, px.password = "u", "p"
|
||||
px.port_start, px.port_end = P_START, P_END
|
||||
port = await px.ensure_port()
|
||||
assert port is not None
|
||||
assert (await store.snapshot(HOST_A))[HOST_A]["held"] == 1
|
||||
|
||||
await px.release()
|
||||
assert (await store.snapshot(HOST_A))[HOST_A]["held"] == 0, "종료 후에는 남이 곧바로 쓸 수 있어야 한다"
|
||||
assert px._leased is None
|
||||
assert await store.acquire(HOST_A, "other-process", 600) is not None
|
||||
|
||||
|
||||
async def test_proxy_release_is_safe_without_a_lease(store):
|
||||
"""임대를 못 잡은 채 종료돼도(풀 고갈 등) 예외 없이 지나가야 한다."""
|
||||
from services.search.proxy import DecodoProxy
|
||||
|
||||
px = DecodoProxy(host=HOST_A, store=store, owner="never-leased")
|
||||
px.username, px.password = "u", "p"
|
||||
px.port_start, px.port_end = P_START, P_END
|
||||
await px.release()
|
||||
|
||||
|
||||
# ── 배타 임대(프로세스 간) ───────────────────────────────────────────────
|
||||
async def test_no_two_owners_get_the_same_port(store):
|
||||
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""
|
||||
|
||||
@ -20,6 +20,7 @@ from crud.bot_detection import BotDetectionLog
|
||||
from crud.ip_session import IpSessionLog
|
||||
from crud.port_lease import PortLeaseStore
|
||||
from crud.price_history import PriceHistory
|
||||
from services.search.browser_base import ENV_BLOCK_MARKER
|
||||
from services.search.profile_slot import claim_profile_slot
|
||||
from services.search.proxy import DecodoProxy
|
||||
from services.search.coupang.adapter import CoupangAdapter
|
||||
@ -147,8 +148,13 @@ async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float
|
||||
|
||||
|
||||
def _fatal_markers(adapters) -> list[str]:
|
||||
"""어댑터들이 선언한 '회전 무효' 마커 목록 — 알림이 이 마커만 세도록 모은다."""
|
||||
out = []
|
||||
"""'회전 무효' 마커 목록 — 알림이 이 마커만 세도록 모은다.
|
||||
|
||||
두 종류를 함께 센다: 어댑터가 HTML 에서 알아보는 구조적 차단 마커와, 연속 패턴으로
|
||||
판정하는 환경 차단(서킷브레이커 트립, ENV_BLOCK_MARKER). 둘 다 IP 회전으로는 회복되지
|
||||
않아 사람이 환경/설정을 고쳐야 한다는 점에서 같은 계열이다.
|
||||
"""
|
||||
out = [ENV_BLOCK_MARKER]
|
||||
for ad in (adapters or []):
|
||||
out += list(getattr(ad, "fatal_block_markers", ()) or ())
|
||||
return sorted(set(out))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user