기존엔 네이버가 쿠팡 기준 예산(3회)을 그대로 썼다. 실측하니 체급이 다르다: 같은 KR IP 로 **12회 연속 검색까지 무차단**(IP 4개 전부 한계 미도달). 3회로 돌리면 불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 회전마다 브라우저 재기동(~20s)이 붙는다. → [DecodoConfig].naver_ip_request_budget = 10 (실측 12 에 여유). 쿠팡은 3 유지. 그리고 선제 회전에 빠져 있던 조각을 채웠다 — **휴식(rest)**: 예산 도달로 놓은 포트를 곧바로 다른 워커가 집으면 그 IP 의 요청률이 도로 올라가 예산의 의미가 사라진다. release(rest_sec=...) 로 sticky 수명만큼 쉬게 한다. 차단으로 태우는 burn(30분)과는 별개 상태다: 휴식 탄 게 아님 · 짧음 · 소진 시 가장 먼저 회수 쿨다운 차단당함 · 김 · 휴식보다 나중에 회수 회전 종류(kind)를 browser_base → DecodoProxy.rotate(kind) 로 전달해 budget 일 때만 휴식을 건다. 라이브 검증(예산 3으로 낮춰 관찰): 6회 검색 = IP 2개만 사용, 3회마다 선제 회전, 놓은 포트는 휴식 1 · 쿨다운 0 · 차단 0. 즉 IP 를 태우지 않고 로테이션만으로 돌아간다. 테스트 6건 추가(휴식 재사용 금지·만료 복귀·burn 우선·회수 우선순위·budget vs block), 전체 202 passed. _MockProxy.rotate 가 kind 를 받도록 갱신.
219 lines
9.2 KiB
Python
219 lines
9.2 KiB
Python
"""포트(=IP 세션) 중재자 테스트.
|
|
|
|
여기서 지키는 계약은 운영 사고와 직결된다:
|
|
- 한 IP 를 두 소유자가 동시에 쓰면 그 IP 가 빨리 탄다
|
|
- 태운 IP 를 다른 소유자가 곧바로 집으면 차단이 전파된다
|
|
- 게이트웨이가 다르면 같은 포트 번호라도 다른 IP 다(실측) — 서로 간섭하면 안 된다
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from services.search.port_registry import PortRegistry
|
|
from services.search.proxy import DecodoProxy
|
|
from config.config_models import DecodoConfig
|
|
|
|
GATE, KR = "gate.decodo.com", "kr.decodo.com"
|
|
|
|
|
|
def _reg(n: int = 5) -> PortRegistry:
|
|
return PortRegistry(10001, 10000 + n)
|
|
|
|
|
|
# ── 배타 할당 ────────────────────────────────────────────────────────────
|
|
def test_same_port_is_never_handed_to_two_owners():
|
|
reg = _reg()
|
|
taken = {reg.acquire(GATE, f"w{i}") for i in range(5)}
|
|
assert len(taken) == 5, "5개 포트를 5명이 나눠 가져야 한다(중복 배정 없음)"
|
|
|
|
|
|
def test_released_port_returns_to_pool():
|
|
reg = _reg(2)
|
|
a = reg.acquire(GATE, "coupang")
|
|
b = reg.acquire(GATE, "naver")
|
|
reg.release(GATE, a, "coupang")
|
|
assert reg.acquire(GATE, "naver2") == a # 반납된 포트가 다시 나온다
|
|
assert b != a
|
|
|
|
|
|
def test_release_by_wrong_owner_is_ignored():
|
|
"""뒤늦은 반납이 남의 리스를 깨면 그 순간 두 소유자가 같은 IP 를 쓰게 된다."""
|
|
reg = _reg(1)
|
|
p = reg.acquire(GATE, "coupang")
|
|
reg.release(GATE, p, "naver") # 남이 반납 시도 → 무시
|
|
assert reg.available(GATE) == 0
|
|
|
|
|
|
# ── 전역 쿨다운 ──────────────────────────────────────────────────────────
|
|
def test_burned_port_is_avoided_by_every_owner():
|
|
reg = _reg(2)
|
|
p = reg.acquire(GATE, "coupang")
|
|
reg.burn(GATE, p, 600, owner="coupang", reason="block")
|
|
other = reg.acquire(GATE, "naver")
|
|
assert other != p, "다른 소유자도 태운 포트를 피해야 한다"
|
|
|
|
|
|
def test_burn_releases_the_lease():
|
|
reg = _reg(3)
|
|
p = reg.acquire(GATE, "coupang")
|
|
reg.burn(GATE, p, 600)
|
|
assert p not in [v for v in reg._held] # 보유 해제
|
|
assert reg.available(GATE) == 2 # 3 - 쿨다운 1
|
|
|
|
|
|
def test_cooldown_expiry_returns_port():
|
|
reg = _reg(1)
|
|
p = reg.acquire(GATE, "coupang")
|
|
reg.burn(GATE, p, -1) # 이미 만료된 쿨다운
|
|
assert reg.available(GATE) == 1
|
|
assert reg.acquire(GATE, "naver") == p
|
|
|
|
|
|
# ── 게이트웨이 격리 ──────────────────────────────────────────────────────
|
|
def test_gateways_do_not_interfere():
|
|
"""같은 포트 번호라도 gate/kr 은 다른 IP(실측) — 한쪽 점유·차단이 다른 쪽을 막으면 안 된다."""
|
|
reg = _reg(1)
|
|
g = reg.acquire(GATE, "coupang")
|
|
k = reg.acquire(KR, "naver")
|
|
assert g == k == 10001 # 번호는 같아도 서로 다른 자원
|
|
reg.burn(GATE, g, 600)
|
|
assert reg.available(GATE) == 0 and reg.available(KR) == 0 # kr 은 여전히 naver 가 보유
|
|
reg.release(KR, k, "naver")
|
|
assert reg.available(KR) == 1
|
|
|
|
|
|
# ── 소진 시 동작 ─────────────────────────────────────────────────────────
|
|
def test_exhausted_pool_reclaims_soonest_expiring():
|
|
"""전부 쿨다운이면 멈추는 대신 가장 빨리 풀릴 포트를 회수한다(가용성 우선)."""
|
|
reg = _reg(2)
|
|
reg.burn(GATE, 10001, 600)
|
|
reg.burn(GATE, 10002, 60) # 얘가 더 빨리 풀린다
|
|
assert reg.acquire(GATE, "naver") == 10002
|
|
|
|
|
|
def test_lease_expiry_is_reported():
|
|
reg = _reg(1)
|
|
p = reg.acquire(GATE, "coupang")
|
|
assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is False
|
|
assert reg.lease_expired(GATE, p, "naver", lease_sec=600) is True # 소유자 아님
|
|
reg._held[(GATE, p)] = ("coupang", time.monotonic() - 700)
|
|
assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is True
|
|
|
|
|
|
def test_snapshot_reports_per_gateway_owners():
|
|
reg = _reg(4)
|
|
reg.acquire(GATE, "coupang-w0")
|
|
reg.acquire(GATE, "coupang-w1")
|
|
reg.acquire(KR, "naver-w0")
|
|
reg.burn(KR, 10004, 600)
|
|
snap = reg.snapshot()
|
|
assert snap[GATE]["held"] == 2 and snap[GATE]["owners"] == {"coupang-w0": 1, "coupang-w1": 1}
|
|
assert snap[KR]["held"] == 1 and snap[KR]["cooling"] == 1
|
|
assert snap[KR]["available"] == 2
|
|
|
|
|
|
# ── DecodoProxy 통합 ─────────────────────────────────────────────────────
|
|
def _cfg() -> DecodoConfig:
|
|
return DecodoConfig(host=GATE, kr_host=KR, username="u", password="p",
|
|
port_start=10001, port_end=10003, session_minutes=10)
|
|
|
|
|
|
def test_two_sources_never_share_a_port_through_registry():
|
|
reg = _reg(3)
|
|
cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
|
|
nav = DecodoProxy(_cfg(), host=KR, registry=reg, owner="naver-w0")
|
|
# 게이트웨이가 다르면 번호가 같아도 무방하지만, 같은 게이트웨이를 공유하면 갈려야 한다
|
|
nav_gate = DecodoProxy(_cfg(), registry=reg, owner="naver-w0")
|
|
assert cou._port() != nav_gate._port()
|
|
assert nav._port() is not None
|
|
|
|
|
|
def test_rotate_releases_and_takes_new_port():
|
|
reg = _reg(3)
|
|
p = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
|
|
first = p._port()
|
|
p.rotate()
|
|
second = p._port()
|
|
assert first != second
|
|
assert reg.available(GATE) == 2 # 이전 포트는 반납돼 풀에 남는다(쿨다운 아님)
|
|
|
|
|
|
def test_mark_burned_goes_global_through_registry():
|
|
reg = _reg(3)
|
|
cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
|
|
nav = DecodoProxy(_cfg(), registry=reg, owner="naver-w0")
|
|
port = cou._port()
|
|
cou.mark_burned(port)
|
|
assert cou.available_ports() == 2 # 3 - 쿨다운 1 (보유 없음)
|
|
assert nav._port() != port, "쿠팡이 태운 포트를 네이버가 집으면 안 된다"
|
|
assert cou.available_ports() == 1 # 3 - 쿨다운 1 - 네이버 보유 1
|
|
|
|
|
|
def test_without_registry_behaviour_is_unchanged():
|
|
"""registry 미주입 경로(단독 사용·기존 테스트)는 그대로 동작해야 한다."""
|
|
p = DecodoProxy(_cfg())
|
|
port = p._port()
|
|
assert 10001 <= port <= 10003
|
|
p.mark_burned(port)
|
|
assert p.available_ports() == 2
|
|
assert p._port() != port
|
|
|
|
|
|
# ── 휴식(선제 회전) ──────────────────────────────────────────────────────
|
|
def test_rested_port_is_not_immediately_reused():
|
|
"""예산 도달로 놓은 IP 를 곧바로 다시 집으면 요청률이 도로 올라가 선제 회전이 무의미해진다."""
|
|
reg = _reg(2)
|
|
p = reg.acquire(GATE, "naver-w0")
|
|
reg.release(GATE, p, "naver-w0", rest_sec=600)
|
|
assert reg.acquire(GATE, "naver-w1") != p
|
|
|
|
|
|
def test_rest_expiry_returns_port_to_pool():
|
|
reg = _reg(1)
|
|
p = reg.acquire(GATE, "naver-w0")
|
|
reg.release(GATE, p, "naver-w0", rest_sec=-1) # 이미 만료된 휴식
|
|
assert reg.available(GATE) == 1
|
|
assert reg.acquire(GATE, "naver-w1") == p
|
|
|
|
|
|
def test_release_without_rest_is_immediately_reusable():
|
|
"""차단·예산이 아닌 평범한 반납(시간창 만료 등)은 휴식 없이 바로 풀에 돌아온다."""
|
|
reg = _reg(1)
|
|
p = reg.acquire(GATE, "naver-w0")
|
|
reg.release(GATE, p, "naver-w0")
|
|
assert reg.acquire(GATE, "naver-w1") == p
|
|
|
|
|
|
def test_burn_overrides_rest():
|
|
reg = _reg(2)
|
|
p = reg.acquire(GATE, "naver-w0")
|
|
reg.release(GATE, p, "naver-w0", rest_sec=60)
|
|
reg.burn(GATE, p, 1800) # 쉬는 중이던 포트가 차단당함
|
|
assert reg.snapshot()[GATE]["cooling"] == 1
|
|
assert reg.snapshot()[GATE]["resting"] == 0
|
|
|
|
|
|
def test_exhausted_pool_reclaims_resting_before_burned():
|
|
"""회수 우선순위: 휴식(탄 게 아님) > 쿨다운(차단). 탄 IP 를 먼저 꺼내 쓰면 안 된다."""
|
|
reg = _reg(2)
|
|
reg.burn(GATE, 10001, 60)
|
|
r = reg.acquire(GATE, "naver-w0") # 10002
|
|
reg.release(GATE, r, "naver-w0", rest_sec=600)
|
|
assert reg.acquire(GATE, "naver-w1") == 10002 # 쉬던 포트를 회수(태운 10001 아님)
|
|
|
|
|
|
def test_budget_rotation_rests_but_block_rotation_burns():
|
|
"""DecodoProxy 통합: 선제 회전은 휴식, 차단은 쿨다운 — 로그·풀 회복 속도가 달라진다."""
|
|
reg = _reg(3)
|
|
p = DecodoProxy(_cfg(), registry=reg, owner="naver-w0")
|
|
first = p._port()
|
|
p.rotate(kind="budget") # 예산 도달 — 태우지 않는다
|
|
snap = reg.snapshot()[GATE]
|
|
assert snap["resting"] == 1 and snap["cooling"] == 0
|
|
assert p._port() != first
|
|
|
|
second = p._port()
|
|
p.mark_burned(second) # 차단 — 쿨다운
|
|
assert reg.snapshot()[GATE]["cooling"] == 1
|