fix(lps): 한 소스가 막혀도 잡을 정상 종료 — '탐색 중'에서 멈추던 원인

운영 로그(2026-08-06)에서 드러난 문제. 쿠팡이 막힌 동안 **네이버가 매번 40건을 가져왔는데도**
잡이 전부 DEAD 로 갔다:

  fail 3c0bcaff → DEAD ({'coupang': {'error': 환경 차단}, 'naver': {'count': 40}})

그리고 DEAD 는 price_history 에 행을 남기지 않는다. 이를 폴링하는 negodata 최저가 모달은
받을 결과가 영영 없어 '탐색 중'에서 멈춘다(사용자 화면 확인). 즉 프론트 버그가 아니라
**백엔드가 답을 안 준 것**이다 — 두 증상이 같은 뿌리였다.

원인: 핸들러가 '하나라도 실패했나'(bool)만 보고 무조건 raise 했다. 한 소스가 막혔다고
다른 소스가 멀쩡히 가져온 결과까지 버린 셈이다.

바꾼 것:
- _search_round 가 **성공한 소스 목록**을 돌려준다(bool → list).
- 살아있는 소스가 하나라도 있으면 그 결과로 진행한다. **전부 죽었을 때만** 재시도한다
  ('없음'이라 단정할 수 없는 건 그때뿐이다).
- 결과에 sources_ok/sources_failed/partial 을 실어 커버리지를 드러낸다(부분 결과 로그도 남김).
- 부분 실패 상태의 not_found 는 **네거티브 캐시에 넣지 않는다** — 막힌 소스엔 있었을 수 있는데
  '없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
- 전부 실패 + 재시도 소진이면 DEAD 대신 outcome='error' 로 **이력을 남기고** 종료한다.
  화면이 무한 대기 대신 결과를 받는 게 중요하다.

negodata 는 변경 없이 받는다(outcome='error' → success_yn=false, fail_reason='error').

테스트 5건 추가·1건 갱신(옛 계약 '한 소스 실패 시 raise' 를 새 계약으로 교체), 전체 274 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-06 16:34:36 +09:00
parent f483839b11
commit 152aed9908
2 changed files with 98 additions and 14 deletions

View File

@ -69,6 +69,15 @@ class FakeNegCache:
self.puts.append(key) self.puts.append(key)
class FakeHistory:
"""price_history 기록 관찰용 — '행이 남는가'가 프론트의 대기/표시를 가른다."""
def __init__(self):
self.events = []
async def record(self, event):
self.events.append(event)
def _np(source, price, mall=None): def _np(source, price, mall=None):
return NormalizedProduct(source=source, name=f"{source}-{price}", price=price, mall_name=mall) return NormalizedProduct(source=source, name=f"{source}-{price}", price=price, mall_name=mall)
@ -130,10 +139,52 @@ async def test_negative_cache_short_circuits():
assert adapters["naver"].calls == [] # 재검색 안 함 assert adapters["naver"].calls == [] # 재검색 안 함
async def test_technical_failure_with_zero_match_raises(): # ── 부분 결과: 한 소스가 막혀도 살아있는 소스로 답한다 (2026-08-06) ──────
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} # 예전엔 한 소스라도 실패하면 무조건 raise → 재시도 → DEAD 였다. 그런데 DEAD 는 price_history 에
# 행을 남기지 않아, 이를 폴링하는 negodata 최저가 모달이 결과를 영영 못 받고 '탐색 중'에서 멈췄다
# (실측: 쿠팡이 막힌 동안 네이버가 40건씩 가져왔는데도 잡이 전부 DEAD).
async def test_all_sources_failing_still_retries():
"""전부 죽었으면 '없음'이라 단정할 수 없다 — 이때는 기존대로 재시도한다."""
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)}
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
await build_search_handler(adapters)(_job()) # 0매칭 + 차단 → 기술 재시도 await build_search_handler(adapters)(_job())
async def test_partial_failure_completes_with_the_surviving_source():
"""쿠팡이 막혀도 네이버가 찾았으면 정상 종료해야 한다(사용자가 결과를 받는 게 우선)."""
adapters = {"coupang": FakeAdapter("coupang", fail=True),
"naver": FakeAdapter("naver", products=[_np("naver", 9000)])}
r = await build_search_handler(adapters, judge=FakeJudge(lambda c: True))(_job())
assert r["outcome"] == "found" and r["lowest"]["price"] == 9000
assert r["partial"] is True and r["sources_failed"] == ["coupang"] and r["sources_ok"] == ["naver"]
async def test_partial_zero_match_ends_as_not_found_not_dead():
"""살아있는 소스가 0매칭이면 not_found 로 **정상 종료**한다 — 잡을 죽이면 화면이 멈춘다."""
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})}
r = await build_search_handler(adapters)(_job())
assert r["outcome"] == "not_found" and r["partial"] is True
async def test_partial_not_found_does_not_poison_the_negative_cache():
"""막힌 소스엔 있었을 수 있다 — '없음'으로 굳히면 TTL 동안 재검색이 막힌다."""
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})}
neg = FakeNegCache()
r = await build_search_handler(adapters, neg_cache=neg)(_job())
assert r["outcome"] == "not_found"
assert neg.puts == [], "부분 실패 상태의 not_found 는 캐시하지 않는다"
async def test_all_sources_failed_on_last_attempt_records_error_instead_of_dying():
"""재시도가 소진되면 DEAD 대신 error 로 기록한다 — 이력이 남아야 화면이 '실패'를 보여준다."""
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)}
hist = FakeHistory()
job = _job()
job.update(attempts=3, max_attempts=3) # 마지막 시도
r = await build_search_handler(adapters, history=hist)(job)
assert r["outcome"] == "error" and r["sources_ok"] == []
assert [e["outcome"] for e in hist.events] == ["error"], "이력이 없으면 프론트가 계속 대기한다"
# ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ────────────────────────── # ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ──────────────────────────

View File

@ -109,19 +109,23 @@ def build_search_handler(
raise raise
async def _search_round(query: str, metrics: SearchMetrics): async def _search_round(query: str, metrics: SearchMetrics):
"""한 라운드: 모든 소스 동시 검색 → (products, per_source, tech_failed). 소스별 시간/바이트 계측.""" """한 라운드: 모든 소스 동시 검색 → (products, per_source, ok_sources). 소스별 시간/바이트 계측.
ok_sources 는 **정상 응답한 소스 목록**이다. 예전엔 '하나라도 실패했나'(bool)만 봤는데,
그러면 한 소스가 막혔을 때 다른 소스가 멀쩡히 가져온 결과까지 버리게 된다(아래 호출부 참고).
"""
results = await asyncio.gather(*[_timed_search(adapters[s], query, s, metrics, False) for s in use], results = await asyncio.gather(*[_timed_search(adapters[s], query, s, metrics, False) for s in use],
return_exceptions=True) return_exceptions=True)
products, per_source, tech_failed = [], {}, False products, per_source, ok_sources = [], {}, []
for src, res in zip(use, results): for src, res in zip(use, results):
if isinstance(res, Exception): if isinstance(res, Exception):
tech_failed = True
per_source[src] = {"error": f"{type(res).__name__}: {res}"} per_source[src] = {"error": f"{type(res).__name__}: {res}"}
LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}")
else: else:
products.extend(res) products.extend(res)
per_source[src] = {"count": len(res)} per_source[src] = {"count": len(res)}
return products, per_source, tech_failed ok_sources.append(src)
return products, per_source, ok_sources
async def _match(target: dict, products: list, base_price, metrics: SearchMetrics): async def _match(target: dict, products: list, base_price, metrics: SearchMetrics):
"""필터 → (있으면) AI 같은상품 판정 → 매칭 후보. AI 토큰은 metrics 에 누적. """필터 → (있으면) AI 같은상품 판정 → 매칭 후보. AI 토큰은 metrics 에 누적.
@ -214,12 +218,14 @@ def build_search_handler(
rounds_done = 0 rounds_done = 0
last_stages, last_sources = [], {} last_stages, last_sources = [], {}
last_ok, last_failed = [], []
async for label, query in _round_queries(base_query, target, metrics): async for label, query in _round_queries(base_query, target, metrics):
if rounds_done >= max_rounds: if rounds_done >= max_rounds:
break break
rounds_done += 1 rounds_done += 1
products, per_source, tech_failed = await _search_round(query, metrics) products, per_source, ok_sources = await _search_round(query, metrics)
failed_sources = [s for s in use if s not in ok_sources]
candidates, stages = apply_filters(products, base_price=base_price) candidates, stages = apply_filters(products, base_price=base_price)
if judge is not None and candidates: if judge is not None and candidates:
verdicts = await judge.judge(target, candidates) verdicts = await judge.judge(target, candidates)
@ -228,6 +234,7 @@ def build_search_handler(
stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)})
candidates = matched candidates = matched
last_stages, last_sources = stages, per_source last_stages, last_sources = stages, per_source
last_ok, last_failed = ok_sources, failed_sources
if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료 if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료
before = len(candidates) before = len(candidates)
@ -236,19 +243,45 @@ def build_search_handler(
stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)}) stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)})
result = rank_result(candidates, len(products), stages, top_n) result = rank_result(candidates, len(products), stages, top_n)
result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done, result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done,
sources=per_source, metrics=metrics.snapshot()) sources=per_source, sources_ok=ok_sources, sources_failed=failed_sources,
partial=bool(failed_sources), metrics=metrics.snapshot())
if failed_sources:
LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다")
await _record_history(cache_key, job.get("job_id"), "found", candidates) await _record_history(cache_key, job.get("job_id"), "found", candidates)
return result return result
if tech_failed: # 0매칭인데 소스가 죽어 있었음 → '없음'이라 단정 불가 → 기술 재시도 # 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** —
raise RuntimeError(f"기술적 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") # 한 소스가 막혔다고 다른 소스가 멀쩡히 가져온 결과까지 버리면 사용자는 아무것도 못 받는다.
# (실측 2026-08-06: 쿠팡이 막힌 동안 네이버가 40건씩 가져왔는데도 잡이 전부 DEAD 로 갔고,
# DEAD 는 price_history 에 행을 안 남겨 프론트가 '탐색 중'에서 멈췄다)
# 전부 죽었을 때만 '없음'이라 단정할 수 없으므로 재시도한다.
if not ok_sources:
# 마지막 시도면 재시도해도 갈 곳이 없다(다음은 DEAD). DEAD 는 price_history 에 행을
# 남기지 않아, 이를 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고
# '탐색 중'에서 멈춘다(2026-08-06 실측). 잡을 죽이는 대신 **실패를 결과로 기록**해
# 화면이 '검색 실패'를 보여줄 수 있게 한다.
if job.get("attempts", 0) >= job.get("max_attempts", 3):
LOG.w(f"[{cache_key}] 모든 소스 실패 + 재시도 소진 → error 로 종료(이력은 남긴다)")
result = rank_result([], 0, stages, top_n)
result.update(outcome="error", query=query, rounds_tried=rounds_done,
sources=per_source, sources_ok=[], sources_failed=failed_sources,
partial=True, metrics=metrics.snapshot())
await _record_history(cache_key, job.get("job_id"), "error", [])
return result
raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}")
# 모든 라운드 클린 0매칭 → 정상 not_found 종료 # 모든 라운드 0매칭 → not_found 로 정상 종료
if neg_cache is not None: # 단, 일부 소스가 막혀 있었다면 **네거티브 캐시에 넣지 않는다** — 그 소스엔 있었을 수 있는데
# '없음'으로 굳혀버리면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
partial = bool(last_failed)
if neg_cache is not None and not partial:
await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds") await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds")
result = rank_result([], 0, last_stages, top_n) result = rank_result([], 0, last_stages, top_n)
result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done, result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done,
sources=last_sources, metrics=metrics.snapshot()) sources=last_sources, sources_ok=last_ok, sources_failed=last_failed,
partial=partial, metrics=metrics.snapshot())
if partial:
LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)")
await _record_history(cache_key, job.get("job_id"), "not_found", []) await _record_history(cache_key, job.get("job_id"), "not_found", [])
return result return result