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:
민헌 2026-08-05 17:09:01 +09:00
parent 042e2d3012
commit 377389f495
7 changed files with 429 additions and 39 deletions

View File

@ -136,10 +136,15 @@ SELECT avg(ip_request_no), count(*) FROM bot_detection;
| `proxy_port` | 사용 포트(=IP 세션). 프록시 미사용이면 NULL | | `proxy_port` | 사용 포트(=IP 세션). 프록시 미사용이면 NULL |
| `requests` | 이 IP로 보낸 요청 수 | | `requests` | 이 IP로 보낸 요청 수 |
| `ok_count` / `blocked_count` | 성공 검색 수 / 차단 감지 수 | | `ok_count` / `blocked_count` | 성공 검색 수 / 차단 감지 수 |
| `elapsed_sec` | 세션 지속 시간(초) | | `elapsed_sec` | IP 세션 지속 시간(초) — 브라우저 수명이 아니라 **그 IP 를 쥔 총 시간** |
| `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(시간창 만료) / `idle`(유휴 정리) / `shutdown`(종료) | | `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(sticky 수명 만료) / `shutdown`(종료) |
| `created_at` | 세션 종료 시각 | | `created_at` | 세션 종료 시각 |
> **IP 세션 ≠ 브라우저 수명** (2026-08-05 변경). 유휴 정리(120s)는 브라우저만 닫고 같은 IP 로
> 돌아오므로 세션이 끝나지 않는다 — 그래서 `idle` 사유는 더 이상 기록되지 않는다.
> 예전엔 재기동마다 카운터가 0 으로 리셋돼 **한 IP 를 계속 쓰면서 `requests` 가 항상 1**
> 남았다(예산이 영영 발화하지 않던 원인). 아래 튜닝 쿼리는 그 시점 이전 데이터엔 쓸 수 없다.
**예산 튜닝 쿼리** — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정: **예산 튜닝 쿼리** — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정:
```sql ```sql
-- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고, -- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고,

View File

@ -101,6 +101,9 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40)
| `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) | | `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) |
| `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) | | `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) |
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 | | `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 |
| `[coupang] 미지의 0건 응답(short_html(NB)) — 태우지 않고 회전만 한다` | 알려진 차단 마커가 없다 = 진짜 '검색결과 없음'일 수 있다. 확신이 없어 30분 쿨다운은 걸지 않는다(회전·재시도만) |
| **`[coupang] 환경 차단 — 서로 다른 IP 3개가 모두 첫 요청부터...`** | 서킷브레이커 트립. IP 문제가 아니라는 판정이라 **포트를 더 태우지 않는다** — 실행 환경/게이트웨이를 확인할 것 |
| `[coupang] 환경 차단 해제 — 검색 성공` | 서킷브레이커 리셋(성공 1회로 자동 해제 — 별도 조치 불필요) |
| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP | | `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP |
| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 | | `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 |
| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) | | `[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) | | `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) |
| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) | | `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) |
| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) | | `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 ```toml
[AlertConfig] [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 | | `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id |
| **네이버 `비정상적인 접근`(2.6KB)** | 해외 IP 로 접근한 것 — **IP 회전으로 회복 불가**. `[DecodoConfig].kr_host` 가 비었거나 오타. 구조적 차단이라 포트를 태우지 않고 즉시 실패하며 `fatal_block` 알림이 뜬다 | | **네이버 `비정상적인 접근`(2.6KB)** | 해외 IP 로 접근한 것 — **IP 회전으로 회복 불가**. `[DecodoConfig].kr_host` 가 비었거나 오타. 구조적 차단이라 포트를 태우지 않고 즉시 실패하며 `fatal_block` 알림이 뜬다 |
| **네이버 `wtm_captcha`(47~63KB)** | IP 평판/세션 — 자동 IP 회전으로 회복. 반복되면 KR 풀 소모 상태(`proxy_port` 테이블) 확인 | | **네이버 `wtm_captcha`(47~63KB)** | IP 평판/세션 — 자동 IP 회전으로 회복. 반복되면 KR 풀 소모 상태(`proxy_port` 테이블) 확인 |
| **`환경 차단` 로그 / `fatal_block` 알림** | 서로 다른 IP 3개가 모두 첫 요청부터 막혔다 = IP 로 설명 안 되는 차단. 포트 소각이 자동으로 멈추니 **풀 고갈을 걱정하지 말고** 실행 환경(컨테이너 vs 호스트)·게이트웨이 국가 설정부터 볼 것. 환경이 회복되면 검색 성공 1회로 자동 해제 |
| **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 | | **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 |
| **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 | | **워커는 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몰 크롤이 통과한 이력이 있다. 실제 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` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지) 2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지)
3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다 3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다
(compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다) (compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다)

View File

@ -53,6 +53,11 @@ _PROXY_ERR_MARKERS = (
) )
# 서킷브레이커가 트립했을 때 남기는 감지 마커. HTML 에서 나오는 마커가 아니라 **연속 패턴**으로
# 판정한 것이라, 알림(worker_main._fatal_markers)이 이 값을 '회전 무효' 계열로 함께 센다.
ENV_BLOCK_MARKER = "env_block"
def is_proxy_error(msg: str) -> bool: def is_proxy_error(msg: str) -> bool:
"""예외 메시지가 프록시 전송 실패(포트/IP 사망·407)인지(순수 함수, 단위 테스트 가능). """예외 메시지가 프록시 전송 실패(포트/IP 사망·407)인지(순수 함수, 단위 테스트 가능).
True 사이트 차단이 아니라 프록시 문제 다른 IP 회전하면 회복 가능.""" True 사이트 차단이 아니라 프록시 문제 다른 IP 회전하면 회복 가능."""
@ -69,6 +74,13 @@ class BrowserSearchAdapter(SearchAdapter):
# 태우면 안 되는 이유: 원인이 IP 가 아닌데 100개를 순서대로 쿨다운시키고, 같은 게이트웨이를 # 태우면 안 되는 이유: 원인이 IP 가 아닌데 100개를 순서대로 쿨다운시키고, 같은 게이트웨이를
# 쓰는 다른 소스(쿠팡)의 풀까지 말려버린다. # 쓰는 다른 소스(쿠팡)의 풀까지 말려버린다.
fatal_block_markers: tuple = () 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 min_result_html: int = 10000
ready_selector: str = "body" ready_selector: str = "body"
ready_timeout_ms: int = 20000 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._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
self._pw = None self._pw = None
self._ctx = None self._ctx = None
self._launched_at = 0.0 self._launched_at = 0.0 # 현재 브라우저 컨텍스트 기동 시각(시간창 재기동 판정용)
self._ip_requests = 0 # 현재 브라우저(IP)로 보낸 요청 수(재기동 시 리셋) # ── 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._current_port = None
self._force_recycle = False self._force_recycle = False
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._ok = 0 self._ok = 0
self._blocked = 0 self._blocked = 0
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용, 재기동 시 리셋) self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용)
self._sess_blocked = 0 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._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백 self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백
self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적) self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적)
@ -155,15 +181,30 @@ class BrowserSearchAdapter(SearchAdapter):
return False return False
return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60 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): async def _ensure_browser(self):
if self._ctx is not None: if not await self._recycle_if_due():
if self._recycle_due(): return
LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)")
if self._end_reason is None: # force 가 아닌 시간창 만료 재기동
self._end_reason = "window"
await self._close_ctx()
else:
return
if self._pw is None: if self._pw is None:
self._pw = await async_playwright().start() self._pw = await async_playwright().start()
kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) 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: if ensure is not None:
await ensure() await ensure()
kwargs["proxy"] = self._proxy.playwright_proxy() 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) self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
if self._block_resources: if self._block_resources:
await self._ctx.route("**/*", self._route) await self._ctx.route("**/*", self._route)
self._launched_at = time.monotonic() 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._ip_requests = 0
self._sess_ok = self._sess_blocked = 0 self._sess_ok = self._sess_blocked = 0
self._end_reason = None
self._force_recycle = False
async def _close_ctx(self): async def _close_ctx(self):
# 밀린 반납·차단을 먼저 DB 에 반영한다 — 여기서 흘리지 않으면 종료 시 유실돼 # 밀린 반납·차단을 먼저 DB 에 반영한다 — 여기서 흘리지 않으면 종료 시 유실돼
@ -201,7 +256,9 @@ class BrowserSearchAdapter(SearchAdapter):
LOG.d(f"[{self.source}] 포트 상태 flush 실패(무시): {type(ex).__name__}") LOG.d(f"[{self.source}] 포트 상태 flush 실패(무시): {type(ex).__name__}")
self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착 self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착
if self._ctx is not None: if self._ctx is not None:
await self._record_session_end() # 세션 종료 기록은 여기서 하지 않는다 — 브라우저를 닫는 것과 IP 세션이 끝나는 것은
# 다른 사건이다(유휴 정리는 브라우저만 닫고 같은 IP 로 돌아온다).
# 기록은 포트가 바뀌는 _begin_ip_session 과 최종 close() 가 담당한다.
try: try:
await self._ctx.close() await self._ctx.close()
finally: finally:
@ -209,15 +266,21 @@ class BrowserSearchAdapter(SearchAdapter):
async def _record_session_end(self): async def _record_session_end(self):
"""IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터. """IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터.
요청이 없던 세션(유휴 정리 )노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다.""" 요청이 없던 세션노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
if self._on_session_end is None or self._ip_requests == 0: if self._on_session_end is None or self._ip_requests == 0:
self._end_reason = None self._end_reason = None
return return
event = {"source": self.source, "proxy_port": self._current_port, event = {"source": self.source, "proxy_port": self._current_port,
"requests": self._ip_requests, "ok_count": self._sess_ok, "blocked_count": self._sess_blocked, "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"} "end_reason": self._end_reason or "window"}
self._end_reason = None self._end_reason = None
# 마감한 세션은 비운다 — 멱등성 확보. 안 비우면 close() 가 두 번 불릴 때(종료 경로가
# 겹치는 경우) 같은 IP 세션이 두 건으로 기록돼 예산 튜닝 통계가 부풀려진다.
self._ip_requests = 0
self._sess_ok = self._sess_blocked = 0
try: try:
await self._on_session_end(event) await self._on_session_end(event)
except Exception as ex: except Exception as ex:
@ -250,6 +313,28 @@ class BrowserSearchAdapter(SearchAdapter):
"""현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False.""" """현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False."""
return self.uses_proxy and self._ip_budget > 0 and self._ip_requests >= self._ip_budget 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): def _rotate_ip(self, reason: str, kind: str = "rotate", warn: bool = True):
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동. """즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.
kind 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망).""" kind 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망)."""
@ -297,6 +382,7 @@ class BrowserSearchAdapter(SearchAdapter):
if products: if products:
self._ok += 1 self._ok += 1
self._sess_ok += 1 self._sess_ok += 1
self._clear_env_block()
self._note_result(True) self._note_result(True)
LOG.d(f"[{self.source}] query={query!r}{len(products)}건 (limit {limit}, ip_req#{self._ip_requests})") LOG.d(f"[{self.source}] query={query!r}{len(products)}건 (limit {limit}, ip_req#{self._ip_requests})")
return products[:limit] return products[:limit]
@ -316,7 +402,30 @@ class BrowserSearchAdapter(SearchAdapter):
f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)") f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요", raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요",
source=self.source, blocked=True, fatal=True) 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) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀) self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
if blocked and self.uses_proxy and block_retries > 0: 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) 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): 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} " 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}") f"elapsed={elapsed}s headless={self._headless} marker={marker!r} query={query!r} html_len={html_len}")
if self._on_detect is not None: if self._on_detect is not None:
@ -342,6 +452,19 @@ class BrowserSearchAdapter(SearchAdapter):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(f"[{self.source}] 감지 기록 실패(무시): {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: async def health(self) -> AdapterHealth:
total = self._ok + self._blocked total = self._ok + self._blocked
rate = (self._ok / total) if total else 0.0 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): async def close_if_idle(self, idle_sec: float):
"""일정 시간 검색이 없으면 브라우저 컨텍스트를 정리(메모리 회수). playwright 는 유지 — """일정 시간 검색이 없으면 브라우저 컨텍스트를 정리(메모리 회수). playwright 는 유지 —
다음 검색 재기동한다. cf_clearance 쿠키는 user_data_dir 남아 재기동해도 유지.""" 다음 검색 재기동한다. cf_clearance 쿠키는 user_data_dir 남아 재기동해도 유지.
**IP 세션은 끝나지 않는다** 임대는 그대로 두고 같은 IP 돌아오므로, 요청 예산 카운터도
이어진다(예전엔 여기서 리셋돼 예산이 발화하지 않았다)."""
if self._ctx is None or self._lock.locked(): # 검색 중이면 건너뜀 if self._ctx is None or self._lock.locked(): # 검색 중이면 건너뜀
return return
if time.monotonic() - self._last_used < idle_sec: if time.monotonic() - self._last_used < idle_sec:
return return
async with self._lock: async with self._lock:
if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec: if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec:
LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리(다음 검색 때 재기동)") LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리"
if self._end_reason is None: f"(IP 세션 유지 · ip_req#{self._ip_requests})")
self._end_reason = "idle"
await self._close_ctx() await self._close_ctx()
async def close(self): async def close(self):
if self._end_reason is None: if self._end_reason is None:
self._end_reason = "shutdown" self._end_reason = "shutdown"
await self._close_ctx() 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: if self._pw is not None:
await self._pw.stop() await self._pw.stop()
self._pw = None self._pw = None

View File

@ -124,6 +124,23 @@ class DecodoProxy:
LOG.w(f"[port] {self.host} 가용 포트 없음 — 전부 임대/휴식/쿨다운 중({self._owner})") LOG.w(f"[port] {self.host} 가용 포트 없음 — 전부 임대/휴식/쿨다운 중({self._owner})")
return self._leased 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): async def flush(self):
"""밀린 반납·차단만 반영(검색 종료·셧다운 시). 포트를 새로 잡지는 않는다.""" """밀린 반납·차단만 반영(검색 종료·셧다운 시). 포트를 새로 잡지는 않는다."""
if self._store is None: if self._store is None:
@ -137,7 +154,13 @@ class DecodoProxy:
await self._store.burn(self.host, port, cd, owner=self._owner, reason="block") await self._store.burn(self.host, port, cd, owner=self._owner, reason="block")
def available_ports(self) -> int: def available_ports(self) -> int:
"""쿨다운 중이 아닌 포트 수(관측·알림용).""" """쿨다운 중이 아닌 포트 수(관측용) — **인메모리 모드 전용**.
DB 장부(store) 모드에서는 값을 믿으면 된다: 차단은 _pending_burn 으로만 쌓였다가
DB 나가므로 로컬 _burned 비어 있고, 다른 프로세스가 태운 포트도 보이지 않아
**항상 크기(전량 가용) 돌려준다**. 장부 모드의 실제 가용량은 비동기 조회라
여기서 없다 PortLeaseStore.snapshot() 쓴다(worker_main._port_pool_status).
"""
if self._registry is not None: if self._registry is not None:
return self._registry.available(self.host) return self._registry.available(self.host)
self._prune_burned() self._prune_burned()

View File

@ -9,10 +9,11 @@ test_coupang_parser / test_openmarket_parser 가 저장 HTML 로 커버.
""" """
import os import os
import time
import pytest 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.contract import NormalizedProduct, AdapterError
from services.search.rate_limiter import RateLimiter 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 mark_burned(self, port, cooldown_sec=None): self.burned.append(port)
def playwright_proxy(self): return None def playwright_proxy(self): return None
@property @property
def current_port(self): return 10001 def current_port(self):
# 회전하면 실제로 포트가 바뀐다 — 고정값이면 '같은 IP 를 계속 쓰는' 버그를 테스트가 못 본다.
return 10001 + self.rotations
class _MockAdapter(BrowserSearchAdapter): class _MockAdapter(BrowserSearchAdapter):
@ -68,11 +71,14 @@ class _MockAdapter(BrowserSearchAdapter):
super().__init__(rate_limiter=RateLimiter(0, 0), **kw) super().__init__(rate_limiter=RateLimiter(0, 0), **kw)
async def _ensure_browser(self): async def _ensure_browser(self):
if self._ctx is None or self._force_recycle: # 회전(force_recycle) 시 재기동 흉내 # 재기동 판단(_recycle_if_due)·IP 세션 경계(_begin_ip_session)는 **실물 코드를 그대로 탄다**
self._ctx = _MockCtx(self._page) # — 브라우저 기동만 흉내낸다. 그래야 이 하니스가 진짜 동작을 검증한다.
self._force_recycle = False if not await self._recycle_if_due():
self._ip_requests = 0 return
self._current_port = self._proxy.current_port if self._proxy else None # 실제 _ensure_browser 와 동일 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 _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
async def _wait_ready(self, page): pass 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(): async def test_persistent_block_exhausts_and_raises_blocked():
# 차단 2회 연속(max_block_retries=1) → 재시도 회전 1회 + 소진 후 '다음 검색용' 회전 1회 → blocked=True 로 실패 # 차단 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: with pytest.raises(AdapterError) as ei:
await ad.search("q") await ad.search("q")
assert ei.value.blocked is True and ad._proxy.rotations == 2 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 == []
# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ────── # ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ──────

View File

@ -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) 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): async def test_no_two_owners_get_the_same_port(store):
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다.""" """서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""

View File

@ -20,6 +20,7 @@ from crud.bot_detection import BotDetectionLog
from crud.ip_session import IpSessionLog from crud.ip_session import IpSessionLog
from crud.port_lease import PortLeaseStore from crud.port_lease import PortLeaseStore
from crud.price_history import PriceHistory 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.profile_slot import claim_profile_slot
from services.search.proxy import DecodoProxy from services.search.proxy import DecodoProxy
from services.search.coupang.adapter import CoupangAdapter 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]: def _fatal_markers(adapters) -> list[str]:
"""어댑터들이 선언한 '회전 무효' 마커 목록 — 알림이 이 마커만 세도록 모은다.""" """'회전 무효' 마커 목록 — 알림이 이 마커만 세도록 모은다.
out = []
종류를 함께 센다: 어댑터가 HTML 에서 알아보는 구조적 차단 마커와, 연속 패턴으로
판정하는 환경 차단(서킷브레이커 트립, ENV_BLOCK_MARKER). IP 회전으로는 회복되지
않아 사람이 환경/설정을 고쳐야 한다는 점에서 같은 계열이다.
"""
out = [ENV_BLOCK_MARKER]
for ad in (adapters or []): for ad in (adapters or []):
out += list(getattr(ad, "fatal_block_markers", ()) or ()) out += list(getattr(ad, "fatal_block_markers", ()) or ())
return sorted(set(out)) return sorted(set(out))