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:
parent
f483839b11
commit
152aed9908
@ -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"], "이력이 없으면 프론트가 계속 대기한다"
|
||||||
|
|
||||||
|
|
||||||
# ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ──────────────────────────
|
# ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ──────────────────────────
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user