o2o-negosium-original/lps/tests/test_port_registry.py
민헌 495e16b951 feat(lps): IP 를 태우지 말고 한계 직전까지 쓰고 쉬게 — 네이버 예산 상향 + 휴식 개념
기존엔 네이버가 쿠팡 기준 예산(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 를 받도록 갱신.
2026-08-05 11:16:37 +09:00

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