diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4a164..487be79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ OpinetClient를 native async 하나로 통합했다. 모든 조회·디버그에 ## [Unreleased] ### Fixed +- 지역별 화면 수집에서 한 지역(시군구)의 '조회' 검색 응답이 시간 초과되면 검색 화면을 새로 열고 + 탭·지역을 다시 고른 뒤 그 지역만 한 번 다시 조회한다(첫 시도의 늦은 응답을 쓰지 않는다). 재시도 + 요청은 `max_search_requests` 상한에서 차감하며, 여유가 없으면 재시도하지 않는다. 지역 선택 단계·결과 + DOM 대기의 시간 초과, 인증 거부·429·차단 화면·파싱 오류는 재시도하지 않는다. 재시도로 성공한 조회는 + 경고 로그와 `OpinetBrowserSnapshot.region_retry_count`로 드러난다. +- 지역 선택·검색 실패 진단이 한글 지역 이름을 모두 `target=non-numeric`으로 뭉개던 문제를 + 고쳤다. 숫자 코드는 그대로, 이름은 한글 음절·숫자만 남겨 12자로 자른 시도/시군구 경로와 + `attempt`/`attempts`를 남긴다. 응답 이후 DOM 대기 시간 초과도 원 예외 문자열 없이 같은 + 형식으로 정제한다(재시도는 하지 않는다). - HTTP 200의 서비스 이용 불가 HTML을 정상 검색 화면으로 오인하지 않고 조기 실패시킨다. 브라우저 응답 대기 오류는 단계와 최근 요청 종류로 진단하며 비밀 URL·본문은 기록하지 않는다. - 서로 다른 검색지역의 같은 주유소 ID를 전역으로 병합해 유효 가격과 주유소/충전소 diff --git a/docs/browser-collection.md b/docs/browser-collection.md index ebbf03f..7bb81ce 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -55,14 +55,29 @@ HTTP 오류·접근 거부·CAPTCHA 응답은 빈 결과로 바꾸지 않고 실 현재 문서와 응답이 모두 오피넷 HTTPS의 정확한 `/searRgSelect.do` 경로와 같은 query인지 확인한 뒤 그 HTML에서 차단 여부를 검사한다. 대기 시간은 collector의 `timeout_ms`를 따른다. 외부 주소·다른 화면으로의 이동과 일반 네트워크 오류는 복구 대상으로 취급하지 않는다. -기본 `timeout_ms`는 60초다. 이는 지연된 화면 갱신을 기다리는 상한일 뿐, 추가 요청이나 -차단·CAPTCHA 응답에 대한 자동 재시도를 뜻하지 않는다. 더 짧거나 긴 제한이 필요한 호출자는 +기본 `timeout_ms`는 60초다. 이는 지연된 화면 갱신을 기다리는 상한이며, 차단·CAPTCHA +응답에 대한 자동 재시도를 뜻하지 않는다. 더 짧거나 긴 제한이 필요한 호출자는 생성자 인자로 명시할 수 있다. +한 지역(시군구 또는 읍면동)의 '조회' 검색 응답이 시간 초과되면 검색 화면을 새로 열고 +(`page.goto`, 출처·차단 검사 포함) 탭과 지역을 처음부터 다시 고른 뒤 그 지역만 **한 번** 다시 +조회한다. 같은 화면에서 다시 조회하면 첫 시도의 늦은 응답이 두 번째 조회의 결과로 쓰일 수 있기 +때문이다. 그래도 실패하면 전체 수집을 실패시킨다. 재시도 사이에도 화면 조작 간격 +(`action_min_seconds`~`action_max_seconds`)을 지키며, 재시도 요청은 +`max_search_requests` 상한에서 차감된다. 첫 조회분(`지역 수 × 2탭`)을 예약하고 남은 +여유가 없으면 재시도하지 않고 `retry=budget-exhausted`로 실패한다. 지역 선택 단계의 시간 초과 +(같은 화면에서 다시 고르면 이미 선택된 값으로 보고 응답 검증과 하위 목록 대기를 건너뛴다), +결과 DOM 대기 시간 초과(차단·빈 화면일 수 있다), HTTP 401/403/429, 차단·서비스 불가 화면, +파싱 오류는 재시도하지 않는다. 재시도로 성공한 조회는 `opinet.experimental.browser` 경고 로그와 +`OpinetBrowserSnapshot.region_retry_count`로 확인한다. + HTTP 200이어도 `The service is not available.` 오류 HTML이면 즉시 실패한다. -탭 전환·지역 선택·검색의 응답 대기 실패는 `OpinetServerError`에 단계·숫자 지역 코드와 -최근 8개 요청/응답/전송 실패 이벤트를 남긴다. 인증 URL의 query, 헤더, 본문, 원 예외 문자열은 -포함하지 않는다. 이 진단은 추가 요청·자동 재시도 없이 같은 화면 동작만 관찰한다. +탭 전환·지역 선택·검색의 응답 대기 실패는 `OpinetServerError`에 단계·지역 식별자와 +최근 8개 요청/응답/전송 실패 이벤트를 남긴다. 지역 식별자는 숫자 지역 코드면 그대로, +오피넷 화면처럼 값이 지역 이름이면 한글 음절·숫자만 남겨 최대 12자로 자른 값을 쓴다 +(예: `phase=search:station:region=경기도/용인시수지구:attempt=2`, 그 밖의 문자열은 +`redacted`). 지역 조회 실패에는 `attempts=1|2`가 붙는다. 인증 URL의 query, 헤더, 본문, +원 예외 문자열은 포함하지 않는다. 진단 자체는 같은 화면 동작만 관찰하며 요청을 추가하지 않는다. HTTP 401/403/429와 취소는 기존 예외 의미를 유지한다. 오류 화면 감지가 과거 모든 timeout 원인의 해결을 뜻하지는 않으며, 실제 수집 복구는 별도로 확인해야 한다. 현재 이 안전 진단 범위는 응답 대기 구간이다. 초기 navigation과 응답 이후 DOM/파싱 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 2a357c9..f8cd51f 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -13,6 +13,10 @@ 차단 화면과 외부 이동, 관련 없는 네트워크 오류는 계속 실패로 처리한다. 운영 수집에서 일시적으로 지연된 화면 응답을 기다릴 수 있도록 collector의 기본 `timeout_ms`는 60초로 둔다. 이 변경은 요청 재시도나 접근 제한 우회를 추가하지 않는다. +2026-10-12부터 지역 조회 한 건의 '조회' 검색 응답이 시간 초과되면 검색 화면을 새로 열고 +탭·지역을 다시 고른 뒤 그 지역만 한 번 다시 조회한다(지역 선택·결과 DOM 대기 시간 초과는 재시도하지 +않는다). 재시도는 화면 조작 간격을 지키고 `max_search_requests` 상한에서 차감되며, +실패 진단에는 `target=non-numeric` 대신 정제·절단한 지역 식별자와 시도 횟수가 남는다. 공식 open API와 분리된 `opinet.experimental.OpinetBrowserCollector`를 추가했습니다. Playwright로 지역별 공개 화면의 시도·시군구·읍면동 선택값을 읽고, 화면 조회 시 diff --git a/src/opinet/experimental/browser.py b/src/opinet/experimental/browser.py index 348ad62..a85cef4 100644 --- a/src/opinet/experimental/browser.py +++ b/src/opinet/experimental/browser.py @@ -10,6 +10,7 @@ import asyncio import html import importlib +import logging import random import time from collections import deque @@ -111,6 +112,56 @@ } +_logger = logging.getLogger(__name__) + +_REGION_LABEL_MAX_CHARS = 12 +_REGION_SEARCH_MAX_ATTEMPTS = 2 +_TAB_SELECTORS: dict[BrowserStationKind, str] = {"station": "#OS_BTN", "lpg": "#LPG_BTN"} + + +class _BrowserActionTimeoutError(OpinetServerError): + """브라우저 응답 대기 시간 초과(탭 전환·지역 선택·조회 공통).""" + + +class _SearchResponseTimeoutError(_BrowserActionTimeoutError): + """'조회' 클릭 뒤 검색 응답 대기 시간 초과. 지역 조회 1회 재시도는 이 경우에만 한다. + + 지역 선택 단계의 시간 초과는 재시도하지 않는다. 같은 화면에서 다시 고르면 이미 선택된 값으로 + 보고 응답 검증과 하위 목록 대기를 건너뛰어, 늦게 온 응답이 다른 지역 결과를 섞을 수 있다. + """ + + +def _region_label(value: str | None) -> str: + """진단 메시지에 남길 지역 식별자를 만든다. + + 숫자 지역 코드는 그대로 쓰고, 이름 값은 한글 음절과 숫자만 남겨 최대 + ``_REGION_LABEL_MAX_CHARS``자로 자른다. URL·인증값 같은 임의 문자열은 + 남기지 않는다. + """ + text = (value or "").strip() + if text.isascii() and text.isdecimal() and len(text) <= 10: + return text + kept = "".join(char for char in text if "\uac00" <= char <= "\ud7a3" or (char.isascii() and char.isdecimal())) + if not kept: + return "redacted" + if len(kept) > _REGION_LABEL_MAX_CHARS: + return kept[:_REGION_LABEL_MAX_CHARS] + "~" + return kept + + +def _region_path_label(region: BrowserRegion) -> str: + """시도/시군구(/읍면동) 경로 형태의 진단용 지역 식별자를 반환한다.""" + parts = [region.sido_value, region.sigungu_value] + if region.dong_value: + parts.append(region.dong_value) + return "/".join(_region_label(part) for part in parts) + + +def _is_timeout(exc: BaseException) -> bool: + """Playwright·asyncio 응답 대기 시간 초과인지 판정한다.""" + return isinstance(exc, (_BrowserActionTimeoutError, TimeoutError)) or type(exc).__name__ == "TimeoutError" + + def _is_allowed_opinet_url(value: str) -> bool: """오피넷 HTTPS 호스트인지 확인한다.""" try: @@ -205,7 +256,12 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m @contextmanager -def _observe_browser_action(page: Any, phase: str) -> Iterator[None]: +def _observe_browser_action( + page: Any, + phase: str, + *, + timeout_error: type[_BrowserActionTimeoutError] = _BrowserActionTimeoutError, +) -> Iterator[None]: """실패 단계와 최근 요청 종류만 남기고 URL·본문·헤더는 노출하지 않는다.""" events: deque[str] = deque(maxlen=8) endpoints = {_PAGE_ENDPOINT, _SEARCH_ENDPOINT, "sigunguGisSelect.do", "geocodeUtmkSelect.do"} @@ -237,7 +293,8 @@ def failed(request: Any) -> None: raise except Exception as exc: # 원 예외 문자열은 인증 URL이나 요청 본문을 포함할 수 있으므로 복제하지 않는다. - raise OpinetServerError( + error_type = timeout_error if _is_timeout(exc) else OpinetServerError + raise error_type( f"Opinet browser action failed: phase={phase}; cause={type(exc).__name__}; " f"events={','.join(events) or 'none'}" ) from None @@ -524,6 +581,8 @@ class OpinetBrowserSnapshot: source_url: str regions: tuple[BrowserRegion, ...] stations: tuple[BrowserStation, ...] + #: 검색 응답 시간 초과 뒤 화면을 다시 열고 한 번 더 조회해 성공한 지역 조회 수. + region_retry_count: int = 0 @dataclass(frozen=True, slots=True) @@ -839,14 +898,7 @@ async def collect_once(self) -> OpinetBrowserSnapshot: browser = await playwright.chromium.launch(**launch_options) context = await browser.new_context() page = await context.new_page() - navigation_response = await page.goto(self.url, wait_until="domcontentloaded", timeout=self.timeout_ms) - if navigation_response is None: - raise OpinetServerError("Opinet browser navigation returned no response") - _validate_response(navigation_response, _PAGE_ENDPOINT) - await _reject_blocked_response(navigation_response, page=page, timeout_ms=self.timeout_ms) - final_url = str(getattr(page, "url", self.url)) - if not _is_allowed_opinet_url(final_url): - raise OpinetServerError(f"Opinet browser navigation redirected to {final_url!r}") + await self._open_search_page(page) await self._pause(page) return await self.collect_page(page) finally: @@ -859,6 +911,17 @@ async def collect_once(self) -> OpinetBrowserSnapshot: elif browser is not None: await browser.close() + async def _open_search_page(self, page: Any) -> None: + """검색 화면을 새로 열고 응답 출처·차단 여부·최종 주소를 검증한다.""" + navigation_response = await page.goto(self.url, wait_until="domcontentloaded", timeout=self.timeout_ms) + if navigation_response is None: + raise OpinetServerError("Opinet browser navigation returned no response") + _validate_response(navigation_response, _PAGE_ENDPOINT) + await _reject_blocked_response(navigation_response, page=page, timeout_ms=self.timeout_ms) + final_url = str(getattr(page, "url", self.url)) + if not _is_allowed_opinet_url(final_url): + raise OpinetServerError(f"Opinet browser navigation redirected to {final_url!r}") + async def collect_page(self, page: Any) -> OpinetBrowserSnapshot: """이미 열린 Playwright 페이지에서 한 번 전체 지역 수집을 실행한다.""" regions = await self._discover_regions(page) @@ -872,19 +935,23 @@ async def collect_page(self, page: Any) -> OpinetBrowserSnapshot: f"{search_request_count} > {self.throttle.max_search_requests}" ) stations: dict[tuple[str, ...], BrowserStation] = {} + # 첫 조회분은 위에서 예약했다. 시간 초과 재시도는 남은 요청 상한에서 차감한다. + retry_budget = self.throttle.max_search_requests - search_request_count - tab_targets: tuple[tuple[BrowserStationKind, str], ...] = ( - ("station", "#OS_BTN"), - ("lpg", "#LPG_BTN"), - ) - for station_kind, tab_selector in tab_targets: + retried = 0 + for station_kind, tab_selector in _TAB_SELECTORS.items(): await self._activate_tab(page, station_kind=station_kind, selector=tab_selector) for region in query_regions: - for station in await self._search_region( + found, region_retried = await self._search_region_with_retry( page, region, station_kind=station_kind, - ): + retry_allowed=retry_budget > 0, + ) + if region_retried: + retry_budget -= 1 + retried += 1 + for station in found: key = _station_key(station) previous = stations.get(key) stations[key] = station if previous is None else _merge_station(previous, station) @@ -894,6 +961,7 @@ async def collect_page(self, page: Any) -> OpinetBrowserSnapshot: source_url=self.url, regions=tuple(regions), stations=tuple(stations.values()), + region_retry_count=retried, ) async def _activate_tab(self, page: Any, *, station_kind: BrowserStationKind, selector: str) -> None: @@ -1011,7 +1079,7 @@ async def _read_options(self, page: Any, selector: str) -> tuple[tuple[str, str] result.append((value, name)) return tuple(result) - async def _select_value(self, page: Any, selector: str, value: str) -> None: + async def _select_value(self, page: Any, selector: str, value: str, *, parent: str | None = None) -> None: locator = page.locator(selector) current = await locator.input_value() if current == value: @@ -1021,8 +1089,8 @@ async def _select_value(self, page: Any, selector: str, value: str) -> None: if region_endpoints is None: await locator.select_option(value=value) else: - # 지역 코드만 진단에 포함한다. 임의 문자열·URL은 기록하지 않는다. - target = value if value.isascii() and value.isdecimal() and len(value) <= 10 else "non-numeric" + # 숫자 지역 코드 또는 정제·절단한 지역 이름만 진단에 포함한다. 임의 문자열·URL은 기록하지 않는다. + target = _region_label(value) if parent is None else f"{_region_label(parent)}/{_region_label(value)}" with _observe_browser_action(page, f"select:{selector}:target={target}"): async with page.expect_response( lambda response: any(_is_post_response(response, endpoint) for endpoint in region_endpoints), @@ -1060,7 +1128,7 @@ async def _discover_regions(self, page: Any) -> list[BrowserRegion]: if not sigungus: raise OpinetServerError(f"Opinet browser returned no sigungu options for {sido_value!r}") for sigungu_value, sigungu_name in sigungus: - await self._select_value(page, "#SIGUNGU_NM0", sigungu_value) + await self._select_value(page, "#SIGUNGU_NM0", sigungu_value, parent=sido_value) dongs = await self._read_options(page, "#DONG_NM") if dongs: regions.extend( @@ -1146,15 +1214,63 @@ async def _read_dom_stations( raise OpinetServerError("Opinet browser returned only malformed station rows") return tuple(stations.values()) + async def _search_region_with_retry( + self, + page: Any, + region: BrowserRegion, + *, + station_kind: BrowserStationKind, + retry_allowed: bool, + ) -> tuple[tuple[BrowserStation, ...], bool]: + """'조회' 검색 응답이 시간 초과되면 화면을 새로 열고 같은 지역을 한 번만 다시 조회한다. + + 재시도는 검색 응답 대기 시간 초과(`_SearchResponseTimeoutError`)에만 한다. 지역 선택 단계 + 시간 초과, 결과 DOM 대기 시간 초과(차단·빈 화면일 수 있다), 인증 거부·429·차단 화면·파싱 오류는 + 재시도하지 않는다. 재시도 전에는 검색 화면을 다시 열고(`page.goto`) 탭을 다시 고른 뒤 지역을 + 처음부터 고르므로, 첫 시도의 늦은 응답이 다시 시도한 조회의 결과로 쓰이지 않는다. 반환값의 두 번째 + 요소는 재시도 요청을 썼는지 여부다(호출자가 요청 상한에서 차감). + """ + try: + return await self._search_region(page, region, station_kind=station_kind), False + except _SearchResponseTimeoutError as exc: + if not retry_allowed: + raise _region_timeout_error(exc, region, station_kind, attempts=1, note="retry=budget-exhausted") from None + except Exception as exc: + if _is_timeout(exc) and not isinstance(exc, OpinetError): + raise _region_timeout_error(exc, region, station_kind, attempts=1) from None + raise + await self._reload_search_page(page, region, station_kind=station_kind) + try: + found = await self._search_region(page, region, station_kind=station_kind, attempt=2) + except Exception as exc: + if not _is_timeout(exc): + raise + raise _region_timeout_error(exc, region, station_kind, attempts=_REGION_SEARCH_MAX_ATTEMPTS) from None + _logger.warning( + "Opinet browser region search succeeded on retry after a search timeout: kind=%s region=%s", + station_kind, + _region_path_label(region), + ) + return found, True + + async def _reload_search_page(self, page: Any, region: BrowserRegion, *, station_kind: BrowserStationKind) -> None: + """재시도 전에 검색 화면을 새로 열고 탭을 다시 고른다(지역은 `_search_region`이 다시 고른다).""" + with _observe_browser_action(page, f"reload:{station_kind}:region={_region_path_label(region)}"): + await self._open_search_page(page) + await self._pause(page) + await self._wait_for_options(page, "#SIDO_NM0") + await self._activate_tab(page, station_kind=station_kind, selector=_TAB_SELECTORS[station_kind]) + async def _search_region( self, page: Any, region: BrowserRegion, *, station_kind: BrowserStationKind, + attempt: int = 1, ) -> tuple[BrowserStation, ...]: await self._select_value(page, "#SIDO_NM0", region.sido_value) - await self._select_value(page, "#SIGUNGU_NM0", region.sigungu_value) + await self._select_value(page, "#SIGUNGU_NM0", region.sigungu_value, parent=region.sido_value) await self._select_value(page, "#DONG_NM", region.dong_value or "") for selector, expected in ( ("#SIDO_NM0", region.sido_value), @@ -1169,9 +1285,8 @@ async def _search_region( ) await self._pause(page) - region_code = region.sigungu_value - target = region_code if region_code.isascii() and region_code.isdecimal() and len(region_code) <= 10 else "non-numeric" - with _observe_browser_action(page, f"search:{station_kind}:target={target}"): + phase = f"search:{station_kind}:region={_region_path_label(region)}:attempt={attempt}" + with _observe_browser_action(page, phase, timeout_error=_SearchResponseTimeoutError): async with page.expect_response( lambda response: _is_post_response(response, _SEARCH_ENDPOINT) or _is_post_response(response, _PAGE_ENDPOINT), @@ -1197,6 +1312,27 @@ async def _search_region( return await self._read_dom_stations(page, region=region, station_kind=station_kind) +def _region_timeout_error( + exc: BaseException, + region: BrowserRegion, + station_kind: BrowserStationKind, + *, + attempts: int, + note: str | None = None, +) -> OpinetServerError: + """지역 조회 시간 초과를 지역 식별자가 담긴 안전한 진단 오류로 만든다.""" + suffix = f"; attempts={attempts}" + (f"; {note}" if note else "") + if isinstance(exc, OpinetServerError): + # 관찰 단계에서 이미 정제된 메시지(단계·지역·최근 이벤트)를 유지한다. + return type(exc)(f"{exc}{suffix}", status_code=exc.status_code, headers=exc.headers) + # 응답 이후 DOM 대기 등에서 난 원 예외 문자열은 복제하지 않는다. + return _BrowserActionTimeoutError( + "Opinet browser region search timed out: " + f"phase=search:{station_kind}:region={_region_path_label(region)}; " + f"cause={type(exc).__name__}{suffix}" + ) + + __all__ = [ "BrowserFuelPrice", "BrowserRegion", diff --git a/tests/test_experimental_browser.py b/tests/test_experimental_browser.py index 6cf7bd9..a20e67f 100644 --- a/tests/test_experimental_browser.py +++ b/tests/test_experimental_browser.py @@ -527,8 +527,11 @@ def test_browser_action_no_response_diagnostic(): @pytest.mark.asyncio -@pytest.mark.parametrize("value,target", [("11680", "11680"), ("secret=value", "non-numeric")]) -async def test_selection_timeout_reports_only_numeric_region_target(value, target): +@pytest.mark.parametrize( + "value,target", + [("11680", "11680"), ("secret=value", "redacted"), ("용인시수지구", "용인시수지구")], +) +async def test_selection_timeout_reports_safe_region_target(value, target): class NoResponsePage(_FakeRegionPage): def expect_response(self, predicate, timeout): raise TimeoutError("secret=value") @@ -786,7 +789,7 @@ async def test_browser_dom_and_search_response_paths(monkeypatch, region): query_level="sigungu", ) - async def select_without_network(_page, _selector, _value): + async def select_without_network(_page, _selector, _value, **_kwargs): return None monkeypatch.setattr(collector, "_select_value", select_without_network) @@ -1115,3 +1118,293 @@ async def sink(_snapshot): await collector.run_forever(sink, stop_event=stop_event) assert called is False + + +def test_region_label_keeps_codes_and_truncates_sanitized_names(): + assert browser_module._region_label("11680") == "11680" + assert browser_module._region_label(" 경기도 ") == "경기도" + assert browser_module._region_label("용인시 수지구") == "용인시수지구" + assert browser_module._region_label("가" * 20) == "가" * 12 + "~" + assert browser_module._region_label("https://evil.invalid/?certkey=abc") == "redacted" + assert browser_module._region_label("") == "redacted" + assert browser_module._region_label(None) == "redacted" + # 숫자 코드라도 10자를 넘으면 그대로 싣지 않고 절단 규칙을 따른다. + assert browser_module._region_label("1" * 20) == "1" * 12 + "~" + + +@pytest.mark.asyncio +async def test_selection_timeout_reports_parent_region(): + class NoResponsePage(_FakeRegionPage): + def expect_response(self, predicate, timeout): + raise TimeoutError("secret=value") + + with pytest.raises(OpinetServerError, match="target=경기도/용인시수지구") as raised: + await OpinetBrowserCollector()._select_value( + NoResponsePage(), "#SIGUNGU_NM0", "용인시수지구", parent="경기도" + ) + assert isinstance(raised.value, browser_module._BrowserActionTimeoutError) + assert not isinstance(raised.value, browser_module._SearchResponseTimeoutError) + + +@pytest.mark.asyncio +async def test_search_timeout_phase_names_region_and_attempt(region): + class TimeoutSearchPage(_FakeSearchPage): + def expect_response(self, _predicate, timeout): + raise TimeoutError("certkey=secret body") + + collector = OpinetBrowserCollector( + throttle=OpinetBrowserThrottle(action_min_seconds=0.0, action_max_seconds=0.0), + ) + page = TimeoutSearchPage(_FakeResponse("https://www.opinet.co.kr/searRgCircleAjax.do")) + page.set_region(region) + with pytest.raises(OpinetServerError) as raised: + await collector._search_region(page, region, station_kind="lpg") + message = str(raised.value) + assert "phase=search:lpg:region=경기도/용인시수지구/풍덕천동:attempt=1" in message + assert "cause=TimeoutError" in message + assert "non-numeric" not in message + assert "secret" not in message + assert isinstance(raised.value, browser_module._SearchResponseTimeoutError) + + +def _scripted_search_collector(monkeypatch, outcomes, *, max_search_requests=10_000): + """``outcomes``를 차례로 돌려주는 가짜 지역 조회를 붙인 collector.""" + collector = OpinetBrowserCollector( + throttle=OpinetBrowserThrottle( + action_min_seconds=0.0, + action_max_seconds=0.0, + max_search_requests=max_search_requests, + ), + query_level="sigungu", + ) + calls = [] + reloads = [] + + async def search(_page, region, *, station_kind, attempt=1): + calls.append((region.sigungu_value, station_kind, attempt)) + outcome = outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + async def reload(_page, region, *, station_kind): + reloads.append((len(calls), region.sigungu_value, station_kind)) + + monkeypatch.setattr(collector, "_search_region", search) + monkeypatch.setattr(collector, "_reload_search_page", reload) + return collector, calls, reloads + + +def _wrapped_timeout(region_text="region=경기도/용인시수지구:attempt=1"): + return browser_module._SearchResponseTimeoutError( + f"Opinet browser action failed: phase=search:station:{region_text}; cause=TimeoutError; events=none" + ) + + +@pytest.mark.asyncio +async def test_region_search_timeout_is_retried_once_then_succeeds(monkeypatch, region): + station = parse_browser_response( + {"list": [_row()]}, region=region, station_kind="station", query_level="sigungu" + )[0] + collector, calls, reloads = _scripted_search_collector(monkeypatch, [_wrapped_timeout(), (station,)]) + + found, retried = await collector._search_region_with_retry( + object(), region, station_kind="station", retry_allowed=True + ) + + assert found == (station,) + assert retried is True + assert calls == [("용인시수지구", "station", 1), ("용인시수지구", "station", 2)] + # 재시도 전에 화면을 다시 연다(첫 시도 뒤, 두 번째 시도 전). + assert reloads == [(1, "용인시수지구", "station")] + + +@pytest.mark.asyncio +async def test_region_search_second_timeout_fails_with_region_and_attempts(monkeypatch, region): + collector, calls, _ = _scripted_search_collector( + monkeypatch, [_wrapped_timeout(), _wrapped_timeout("region=경기도/용인시수지구:attempt=2")] + ) + + with pytest.raises(browser_module._BrowserActionTimeoutError) as raised: + await collector._search_region_with_retry(object(), region, station_kind="station", retry_allowed=True) + + message = str(raised.value) + assert "region=경기도/용인시수지구:attempt=2" in message + assert message.endswith("; attempts=2") + assert len(calls) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", + [ + OpinetRateLimitError("limited"), + OpinetAuthError("denied"), + OpinetServerError("Opinet browser response appears to be an access-block page"), + ValueError("parse"), + # 지역 선택 단계의 시간 초과도 재시도하지 않는다. + browser_module._BrowserActionTimeoutError("phase=select:#SIGUNGU_NM0:target=경기도/중구"), + ], +) +async def test_region_search_non_retryable_errors_are_not_retried(monkeypatch, region, error): + collector, calls, reloads = _scripted_search_collector(monkeypatch, [error, ()]) + + with pytest.raises(type(error)) as raised: + await collector._search_region_with_retry(object(), region, station_kind="station", retry_allowed=True) + + assert raised.value is error + assert len(calls) == 1 + assert reloads == [] + + +@pytest.mark.asyncio +async def test_raw_dom_timeout_is_not_retried_and_reported_without_original_text(monkeypatch, region): + """결과 DOM 대기 시간 초과는 차단·빈 화면일 수 있어 재시도하지 않고, 지역만 붙여 정제한다.""" + raw = TimeoutError("Timeout 60000ms exceeded waiting for certkey=secret") + collector, calls, reloads = _scripted_search_collector(monkeypatch, [raw, ()]) + + with pytest.raises(OpinetServerError) as raised: + await collector._search_region_with_retry(object(), region, station_kind="lpg", retry_allowed=True) + + message = str(raised.value) + assert "phase=search:lpg:region=경기도/용인시수지구/풍덕천동" in message + assert message.endswith("; attempts=1") + assert "secret" not in message + assert len(calls) == 1 + assert reloads == [] + + +@pytest.mark.asyncio +async def test_selection_stage_timeout_is_not_retried_on_the_real_path(region): + """지역 선택 응답이 오지 않으면 같은 화면에서 다시 고르지 않는다(늦은 응답·같은 이름 시군구 혼입 방지).""" + + class NoSelectResponsePage(_FakeRegionPage): + def __init__(self): + super().__init__() + self.gotos = [] + + def expect_response(self, predicate, timeout): + raise TimeoutError("late dropdown response") + + async def goto(self, url, **_kwargs): + self.gotos.append(url) + raise AssertionError("must not reload for a selection timeout") + + collector = OpinetBrowserCollector( + throttle=OpinetBrowserThrottle(action_min_seconds=0.0, action_max_seconds=0.0), + ) + page = NoSelectResponsePage() + with pytest.raises(browser_module._BrowserActionTimeoutError) as raised: + await collector._search_region_with_retry(page, region, station_kind="station", retry_allowed=True) + + assert not isinstance(raised.value, browser_module._SearchResponseTimeoutError) + assert "phase=select:#SIDO_NM0:target=경기도" in str(raised.value) + assert page.gotos == [] + + +@pytest.mark.asyncio +async def test_search_click_timeout_reloads_page_and_reselects_before_retry(region): + """조회 응답 시간 초과 뒤에는 화면을 새로 열고 탭·지역을 다시 고른 다음에만 다시 조회한다.""" + region = replace(region, dong_value=None, dong_name=None) + + class RetryPage(_FakeSearchPage): + def __init__(self): + super().__init__(_FakeResponse("https://www.opinet.co.kr/searRgCircleAjax.do", payload={"list": [_row()]})) + self.url = "https://www.opinet.co.kr/searRgSelect.do" + self.events = [] + self.searches = 0 + self.selectors["#OS_BTN"] = _FakeLocator(attributes={"class": "on", "aria-selected": "true"}) + self.set_region(region) + + def expect_response(self, predicate, timeout): + self.searches += 1 + self.events.append(f"search{self.searches}") + if self.searches == 1: + raise TimeoutError("first search response never arrived") + return _FakeResponseContext(self.response) + + async def goto(self, url, *, wait_until, timeout): + assert wait_until == "domcontentloaded" and timeout > 0 + self.events.append("goto") + # 새 화면은 선택값이 비어 있다. 재시도는 이를 다시 골라야 한다. + for selector in ("#SIDO_NM0", "#SIGUNGU_NM0", "#DONG_NM"): + self.selectors[selector] = _FakeLocator(current="") + return _FakeResponse(url, content_type="text/html", text_body="지역 검색") + + selected = [] + collector = OpinetBrowserCollector( + throttle=OpinetBrowserThrottle(action_min_seconds=0.0, action_max_seconds=0.0), + ) + page = RetryPage() + + async def select(page_, selector, value, *, parent=None): + selected.append((page_.events[-1] if page_.events else "start", selector, value)) + page_.selectors[selector] = _FakeLocator(current=value) + + collector._select_value = select + + found, retried = await collector._search_region_with_retry( + page, region, station_kind="station", retry_allowed=True + ) + + assert retried is True + assert len(found) == 1 + assert page.events == ["search1", "goto", "search2"] + # 두 번째 조회 전의 지역 선택은 새로 연 화면(goto 뒤)에서 다시 이뤄진다. + assert [item for item in selected if item[0] == "goto"] == [ + ("goto", "#SIDO_NM0", "경기도"), + ("goto", "#SIGUNGU_NM0", "용인시수지구"), + ("goto", "#DONG_NM", ""), + ] + + +@pytest.mark.asyncio +async def test_region_retry_counts_against_search_request_budget(monkeypatch, region): + first = replace(region, dong_value=None, dong_name=None) + other = replace(first, sigungu_value="수원시장안구", sigungu_name="수원시장안구") + + async def discover(_page): + return [first, other] + + # 2개 지역 x 2탭 = 4건 예약, 상한 5 => 재시도 여유 1건. + collector, calls, _ = _scripted_search_collector( + monkeypatch, + [_wrapped_timeout(), (), _wrapped_timeout()], + max_search_requests=5, + ) + monkeypatch.setattr(collector, "_discover_regions", discover) + + with pytest.raises(browser_module._BrowserActionTimeoutError) as raised: + await collector.collect_page(_FakeTabPage()) + + # 첫 지역은 한 번 재시도해 성공, 다음 지역의 시간 초과는 상한 때문에 재시도하지 않는다. + assert calls == [ + ("용인시수지구", "station", 1), + ("용인시수지구", "station", 2), + ("수원시장안구", "station", 1), + ] + assert str(raised.value).endswith("; attempts=1; retry=budget-exhausted") + + +@pytest.mark.asyncio +async def test_region_retry_within_budget_completes_collection(monkeypatch, region): + first = replace(region, dong_value=None, dong_name=None) + station = parse_browser_response( + {"list": [_row()]}, region=first, station_kind="station", query_level="sigungu" + )[0] + + async def discover(_page): + return [first] + + collector, calls, _ = _scripted_search_collector( + monkeypatch, + [_wrapped_timeout(), (station,), _wrapped_timeout(), ()], + ) + monkeypatch.setattr(collector, "_discover_regions", discover) + + snapshot = await collector.collect_page(_FakeTabPage()) + + assert [attempt for *_, attempt in calls] == [1, 2, 1, 2] + assert [kind for _, kind, _ in calls] == ["station", "station", "lpg", "lpg"] + assert len(snapshot.stations) == 1 + assert snapshot.region_retry_count == 2