Files
App_Scraper/netutil.py
T
dhruv.godvani 03bf0fc923 Initial commit: App Store + Google Play scraper
- discover.py: find app IDs (Play search, iTunes Search API, RSS charts) -> apps.json
- scraper.py: fetch metadata + ratings per app/country, threaded, shardable, resumable
- netutil.py: shared retry/backoff + adaptive circuit breaker (rate-limit safe)
- merge_csv.py: combine sharded outputs from multiple machines
- ARCHITECTURE.html: full architecture & flow documentation
- english_words.txt: deep search-term wordlist

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:54:18 +05:30

150 lines
5.2 KiB
Python

"""
netutil.py — shared network safety: error classification + an adaptive,
thread-safe throttle / circuit breaker used by scraper.py and discover.py.
Goal: NEVER hammer a store into blocking our IP. Being slow is fine; getting
blocked is not. So the moment we see a rate-limit signal (or a run of
unexplained errors), every worker pauses for a cooldown that grows on repeat
and relaxes again once requests succeed.
"""
import logging
import re
import threading
import time
log = logging.getLogger("net")
# HTTP statuses that mean "you're going too fast / not allowed" -> back off hard.
RATE_LIMIT_CODES = {403, 429}
# Temporary server hiccups -> short retry.
TRANSIENT_CODES = {408, 500, 502, 503, 504}
def http_status(exc: Exception):
"""Best-effort extract an HTTP status code from any exception."""
resp = getattr(exc, "response", None)
code = getattr(resp, "status_code", None)
if isinstance(code, int):
return code
m = re.search(r"\b(\d{3})\b", str(exc)) # libs often embed the code in the message
return int(m.group(1)) if m else None
def is_not_found(exc: Exception) -> bool:
if http_status(exc) == 404:
return True
return "notfound" in type(exc).__name__.lower()
def is_rate_limited(exc: Exception) -> bool:
if http_status(exc) in RATE_LIMIT_CODES:
return True
msg = str(exc).lower()
return (
"429" in msg
or "too many requests" in msg
or ("rate" in msg and "limit" in msg)
or "quota" in msg
or "throttl" in msg
)
def is_transient(exc: Exception) -> bool:
if http_status(exc) in TRANSIENT_CODES:
return True
name = type(exc).__name__.lower()
return any(w in name for w in ("timeout", "connection", "ssl", "chunked"))
class Throttle:
"""
Adaptive, shared-across-threads throttle + circuit breaker.
- wait() : call BEFORE each request; blocks while a cooldown is active.
- record_success() : call after a good response; relaxes the throttle.
- record_fail(rate) : call after a failure; trips the cooldown if it's a
rate-limit OR enough consecutive failures pile up.
"""
def __init__(self, cooldown_start: float = 30.0, cooldown_max: float = 300.0,
fail_threshold: int = 5):
self.lock = threading.Lock()
self.cooldown_start = cooldown_start
self.cooldown_max = cooldown_max
self.fail_threshold = fail_threshold
self.current_cooldown = cooldown_start
self.pause_until = 0.0 # monotonic time until which everyone waits
self.extra_delay = 0.0 # added spacing that lingers after a trip
self.consecutive_fail = 0
def wait(self) -> None:
while True:
with self.lock:
remaining = self.pause_until - time.monotonic()
extra = self.extra_delay
if remaining > 0:
time.sleep(min(remaining, 5.0))
continue
break
if extra:
time.sleep(extra)
def record_success(self) -> None:
with self.lock:
self.consecutive_fail = 0
if self.extra_delay > 0:
self.extra_delay = max(0.0, self.extra_delay - 0.25)
if self.current_cooldown > self.cooldown_start:
self.current_cooldown = max(
self.cooldown_start, self.current_cooldown * 0.9
)
def record_fail(self, rate_limited: bool) -> float:
"""Return the cooldown (seconds) if the breaker tripped, else 0."""
with self.lock:
self.consecutive_fail += 1
tripped = rate_limited or self.consecutive_fail >= self.fail_threshold
if not tripped:
return 0.0
cd = self.current_cooldown
self.pause_until = max(self.pause_until, time.monotonic() + cd)
self.current_cooldown = min(self.current_cooldown * 2, self.cooldown_max)
self.extra_delay = min(self.extra_delay + 1.0, 15.0)
self.consecutive_fail = 0
return cd
def retry_call(fn, *args, retries: int = 6, base: float = 2.0,
max_wait: float = 120.0, throttle: "Throttle | None" = None):
"""
Sequential call-with-retry. Waits out rate limits (slow but safe).
Re-raises not-found immediately (don't retry a healthy 404) and re-raises
after the retry budget is exhausted.
"""
attempt = 0
while True:
if throttle:
throttle.wait()
try:
result = fn(*args)
if throttle:
throttle.record_success()
return result
except Exception as exc:
if is_not_found(exc):
if throttle:
throttle.record_success()
raise
rate = is_rate_limited(exc)
if throttle:
throttle.record_fail(rate)
attempt += 1
if attempt > retries:
raise
wait = min(base * (2 ** (attempt - 1)), max_wait)
if rate:
wait = max(wait, 30.0) # rate limits: wait it out, never rush back
log.warning("retry %d/%d in %.0fs (%s)", attempt, retries, wait, exc)
time.sleep(wait)