3232)
3333
3434
35+ # "Come back later" is not "this link is dead". A host that rate-limits us says
36+ # nothing about whether the page exists, so these answers must never be cached as
37+ # a verdict — otherwise one impatient run marks a whole host dead for a TTL.
38+ TRANSIENT_STATUSES = frozenset ({429 , 503 })
39+ RETRY_ATTEMPTS = 3
40+ RETRY_BACKOFF_S = (2.0 , 6.0 )
41+ MAX_RETRY_AFTER_S = 15.0
42+ # How much to slow a host down for the rest of the run once it has pushed back.
43+ RATE_LIMIT_PENALTY = 4.0
44+
45+
3546class CheckResult (NamedTuple ):
3647 url : str
3748 status : int | None
3849 final_url : str | None
3950 alive : bool
4051 reason : str
4152
53+ @property
54+ def transient (self ) -> bool :
55+ return self .status in TRANSIENT_STATUSES
56+
4257
4358# --- opener abstraction (injectable for tests) -----------------------------------
4459
@@ -93,10 +108,21 @@ def classify(original_url: str, status: int | None, final_url: str | None) -> tu
93108 return True , f"http-{ status } "
94109
95110
96- def check_one (url : str , opener : Any ) -> CheckResult :
97- """HEAD first; fall back to GET when HEAD is rejected (405/403) or errors."""
111+ def _retry_after_seconds (exc : Exception ) -> float | None :
112+ """Seconds requested by a ``Retry-After`` header, clamped to something sane."""
113+ headers = getattr (exc , "headers" , None )
114+ raw = headers .get ("Retry-After" ) if headers is not None else None
115+ try :
116+ return min (float (raw ), MAX_RETRY_AFTER_S ) if raw is not None else None
117+ except (TypeError , ValueError ):
118+ return None # HTTP-date form; fall back to our own backoff
119+
120+
121+ def _attempt (url : str , opener : Any ) -> tuple [int | None , str | None , float | None ]:
122+ """One HEAD-then-GET pass. Returns (status, final_url, retry_after)."""
98123 status : int | None = None
99124 final : str | None = None
125+ retry_after : float | None = None
100126 for method in ("HEAD" , "GET" ):
101127 try :
102128 status , final = opener .open (url , method )
@@ -107,10 +133,31 @@ def check_one(url: str, opener: Any) -> CheckResult:
107133 code = getattr (exc , "code" , None )
108134 if isinstance (code , int ):
109135 status , final = code , getattr (exc , "url" , None ) or url
136+ retry_after = _retry_after_seconds (exc )
110137 if method == "HEAD" and code in (400 , 403 , 405 , 501 ):
111138 continue
112139 break
113140 status , final = None , None
141+ return status , final , retry_after
142+
143+
144+ def check_one (
145+ url : str , opener : Any , * , on_rate_limit : Callable [[str ], None ] | None = None
146+ ) -> CheckResult :
147+ """HEAD first; fall back to GET when HEAD is rejected (405/403) or errors.
148+
149+ A rate-limit answer (429/503) is retried with backoff — the host is telling us
150+ to wait, not that the page is gone.
151+ """
152+ status = final = retry_after = None
153+ for attempt in range (RETRY_ATTEMPTS ):
154+ status , final , retry_after = _attempt (url , opener )
155+ if status not in TRANSIENT_STATUSES :
156+ break
157+ if on_rate_limit is not None :
158+ on_rate_limit (host_of (url ))
159+ if attempt < RETRY_ATTEMPTS - 1 :
160+ time .sleep (retry_after if retry_after is not None else RETRY_BACKOFF_S [attempt ])
114161 alive , reason = classify (url , status , final )
115162 return CheckResult (url , status , final , alive , reason )
116163
@@ -124,13 +171,26 @@ class HostRateLimiter:
124171 def __init__ (self , min_interval : float = 1.0 ) -> None :
125172 self .min_interval = min_interval
126173 self ._last : dict [str , float ] = {}
174+ self ._interval : dict [str , float ] = {}
127175 self ._lock = threading .Lock ()
128176
177+ def interval_for (self , host : str ) -> float :
178+ return self ._interval .get (host , self .min_interval )
179+
180+ def back_off (self , host : str , factor : float = RATE_LIMIT_PENALTY ) -> None :
181+ """A host pushed back — slow it down for the rest of the run.
182+
183+ Without this, one rate-limited host keeps being hammered at the global
184+ pace and every subsequent URL on it comes back 429.
185+ """
186+ with self ._lock :
187+ self ._interval [host ] = self .interval_for (host ) * factor
188+
129189 def wait (self , host : str ) -> None :
130190 with self ._lock :
131191 now = time .time ()
132192 prev = self ._last .get (host , 0.0 )
133- sleep_for = max (0.0 , self .min_interval - (now - prev ))
193+ sleep_for = max (0.0 , self .interval_for ( host ) - (now - prev ))
134194 self ._last [host ] = now + sleep_for
135195 if sleep_for > 0 :
136196 time .sleep (sleep_for )
@@ -172,7 +232,7 @@ def _get_opener() -> Any:
172232
173233 def _task (url : str ) -> CheckResult :
174234 limiter .wait (host_of (url ))
175- return check_one (url , _get_opener ())
235+ return check_one (url , _get_opener (), on_rate_limit = limiter . back_off )
176236
177237 if not urls :
178238 return []
@@ -184,7 +244,18 @@ def _task(url: str) -> CheckResult:
184244
185245
186246def load_cache (path : Path = URL_CACHE_PATH ) -> dict [str , dict [str , Any ]]:
187- return {e ["url" ]: e for e in ledger .iter_entries (path ) if isinstance (e .get ("url" ), str )}
247+ """Load the cache, dropping rate-limit answers written by older runs.
248+
249+ A 429/503 is not a verdict, so an entry holding one is not a cache hit —
250+ it is a URL we still have to check. Filtering on load heals a cache that a
251+ previous run poisoned (3,998 GSMArena pages were parked as dead this way,
252+ all of which answer 200 when asked at a civil pace).
253+ """
254+ return {
255+ e ["url" ]: e
256+ for e in ledger .iter_entries (path )
257+ if isinstance (e .get ("url" ), str ) and e .get ("status" ) not in TRANSIENT_STATUSES
258+ }
188259
189260
190261def _parse_ts (ts : str ) -> datetime | None :
0 commit comments