Retry-on-empty guard for scrape-flaky boards

RCA of a prod "Naukri returned 0" on an AI-Architect search: NOT a cursor issue (cursors removed;
naukri-feed is a date feed with no page param), NOT a city-name or keyword bug — the EXACT same input
flaked 0 then returned 15 on retry. The scrape-based actors (Naukri-feed, curious_coder LinkedIn)
intermittently return 0 under the site's anti-scrape rate-limiting, then recover.

Fix: _fetch_board retries a board ONCE (fresh) when it returns 0. One guard covers BOTH the Naukri and
LinkedIn intermittency (cleaner than per-board fallbacks). A genuinely-empty query just returns 0 again
(cheap). 67 tests pass (3 new: recovers transient 0, no retry on success, no infinite loop).
This commit is contained in:
raulgupta
2026-06-26 17:05:30 +05:30
parent d458ed1b34
commit 538d2fc5f3
2 changed files with 59 additions and 1 deletions

View File

@@ -93,7 +93,14 @@ BOARDS = {
async def _fetch_board(key: str, prefs: dict, cache_mode: str | None = None):
actor, build, norm = BOARDS[key]
items = await run_actor(actor, build(prefs), cache_mode=cache_mode)
inp = build(prefs)
items = await run_actor(actor, inp, cache_mode=cache_mode)
if not items:
# Scrape-based boards (Naukri-feed, curious_coder LinkedIn) intermittently return 0 under the
# site's anti-scrape rate-limiting, then recover. One fresh retry rescues the transient case
# (a genuinely-empty query just returns 0 again — cheap). RCA'd: same input flakes 0 then 15.
logger.info("board %s returned 0 — retrying once (transient scrape flake)", key)
items = await run_actor(actor, inp, cache_mode="refresh")
jobs = []
for it in items:
sj = norm(it)

View File

@@ -0,0 +1,51 @@
"""Retry-on-empty guard — scrape-flaky boards (Naukri-feed, curious_coder LinkedIn) intermittently
return 0; one fresh retry rescues the transient case. RCA: the same input flaked 0 then returned 15."""
import asyncio
from app.engine import search
def _run(coro):
return asyncio.run(coro)
def test_retry_on_empty_recovers_transient_zero(monkeypatch):
calls = {"n": 0}
async def fake_run_actor(actor, inp, cache_mode=None):
calls["n"] += 1
# first call flakes (0), retry returns a real job
if calls["n"] == 1:
return []
return [{"profile_job_title": "Data Architect", "branch_company_name": "Acme",
"branch_location_city_name": "Delhi", "skills": ["python"], "job_id": "1"}]
monkeypatch.setattr(search, "run_actor", fake_run_actor)
key, jobs = _run(search._fetch_board("workindia", {"title": "Data Architect", "location": ["Delhi · India"]}))
assert calls["n"] == 2, "a board returning 0 must be retried exactly once"
assert len(jobs) == 1, "the retry's jobs are used"
def test_no_retry_when_first_call_succeeds(monkeypatch):
calls = {"n": 0}
async def fake_run_actor(actor, inp, cache_mode=None):
calls["n"] += 1
return [{"profile_job_title": "X", "branch_company_name": "Y",
"branch_location_city_name": "Delhi", "skills": [], "job_id": "1"}]
monkeypatch.setattr(search, "run_actor", fake_run_actor)
_run(search._fetch_board("workindia", {"title": "X", "location": ["Delhi · India"]}))
assert calls["n"] == 1, "a board that returns jobs is NOT retried (no extra cost)"
def test_genuinely_empty_returns_empty_after_one_retry(monkeypatch):
calls = {"n": 0}
async def fake_run_actor(actor, inp, cache_mode=None):
calls["n"] += 1
return [] # genuinely empty (e.g. WorkIndia for a tech role)
monkeypatch.setattr(search, "run_actor", fake_run_actor)
key, jobs = _run(search._fetch_board("workindia", {"title": "AI Architect", "location": ["Delhi · India"]}))
assert calls["n"] == 2 and jobs == [], "retry once, then accept the empty result (no infinite loop)"