@@ -57,34 +57,65 @@ def load_graph_edge_classifier_from_cache(
5757 model_path = cache_path / model_filename
5858
5959 if not model_path .exists ():
60- logging .info (f"Downloading XGBoost model → { model_path } ..." )
61- response = requests .get (download_url , stream = True )
62- response .raise_for_status ()
63- expected_size = int (response .headers .get ("Content-Length" , - 1 ))
64-
65- with model_path .open ("wb" ) as f :
66- for chunk in response .iter_content (chunk_size = 1024 * 1024 ):
67- if chunk :
68- f .write (chunk )
69-
70- if expected_size != - 1 :
71- actual = model_path .stat ().st_size
72- if actual != expected_size :
73- model_path .unlink (missing_ok = True )
60+ _download_xgboost_model (download_url , model_path )
61+
62+ return GraphEdgeClassifier .load (model_path )
63+
64+
65+ def _download_xgboost_model (
66+ download_url : str ,
67+ model_path : Path ,
68+ num_attempts : int = 3 ,
69+ connect_timeout : float = 10.0 ,
70+ read_timeout : float = 30.0 ,
71+ ) -> None :
72+ """Download the XGBoost model to `model_path`, retrying on transient network errors.
73+
74+ Downloads to a temporary file first and only renames it into place once fully
75+ verified, so a killed or timed-out download never leaves a corrupt file behind
76+ that a later run would mistake for a valid cache hit.
77+ """
78+ tmp_path = model_path .with_suffix (model_path .suffix + ".part" )
79+ last_error : Optional [Exception ] = None
80+
81+ for attempt in range (1 , num_attempts + 1 ):
82+ try :
83+ logging .info (f"Downloading XGBoost model → { model_path } (attempt { attempt } /{ num_attempts } ) ..." )
84+ response = requests .get (download_url , stream = True , timeout = (connect_timeout , read_timeout ))
85+ response .raise_for_status ()
86+ expected_size = int (response .headers .get ("Content-Length" , - 1 ))
87+
88+ with tmp_path .open ("wb" ) as f :
89+ for chunk in response .iter_content (chunk_size = 1024 * 1024 ):
90+ if chunk :
91+ f .write (chunk )
92+
93+ if expected_size != - 1 :
94+ actual = tmp_path .stat ().st_size
95+ if actual != expected_size :
96+ raise RuntimeError (
97+ f"Download incomplete: got { actual } bytes, expected { expected_size } ."
98+ )
99+
100+ h = hashlib .sha256 (tmp_path .read_bytes ()).hexdigest ()
101+ if h != _XGBOOST_SHA256 :
74102 raise RuntimeError (
75- f"Download incomplete : got { actual } bytes , expected { expected_size } ."
103+ f"Downloaded model checksum mismatch : got { h } , expected { _XGBOOST_SHA256 } ."
76104 )
77105
78- h = hashlib .sha256 (model_path .read_bytes ()).hexdigest ()
79- if h != _XGBOOST_SHA256 :
80- model_path .unlink (missing_ok = True )
81- raise RuntimeError (
82- f"Downloaded model checksum mismatch: got { h } , expected { _XGBOOST_SHA256 } . "
83- "The file has been removed — please try again."
84- )
85- logging .info ("XGBoost model downloaded and verified." )
86-
87- return GraphEdgeClassifier .load (model_path )
106+ tmp_path .rename (model_path )
107+ logging .info ("XGBoost model downloaded and verified." )
108+ return
109+ except (requests .exceptions .RequestException , RuntimeError ) as e :
110+ last_error = e
111+ tmp_path .unlink (missing_ok = True )
112+ logging .warning (f"XGBoost model download attempt { attempt } /{ num_attempts } failed: { e } " )
113+
114+ raise RuntimeError (
115+ f"Failed to download the XGBoost model from { download_url } after { num_attempts } attempts: "
116+ f"{ last_error } . You can also download it manually and place it at { model_path } "
117+ "(or point PROTAC_SPLITTER_CACHE_DIR at a directory that already contains it)."
118+ ) from last_error
88119
89120
90121# ---------------------------------------------------------------------------
0 commit comments