99
1010import logging
1111import time
12+ from collections import deque
1213
1314import pandas as pd
1415import requests
1718
1819from epytope .IO .ADBAdapter import ADBAdapter , EAdapterFields , EIdentifierTypes
1920
21+
22+ class EnsemblRESTError (Exception ):
23+ """Definitive failure talking to the Ensembl REST API (not a 'not found')."""
24+
25+
26+ class EnsemblRateLimitError (EnsemblRESTError ):
27+ """429 rate-limit retries exhausted."""
28+
29+
30+ class EnsemblConnectionError (EnsemblRESTError ):
31+ """Connection error or timeout talking to the Ensembl REST API."""
32+
33+
34+ class _RateLimiter :
35+ """Sliding-window limiter enforcing several (max_calls, period_seconds) caps.
36+
37+ Blocks in ``acquire()`` until making a call would not breach any window.
38+ Not thread-safe (the adapter is used single-threaded).
39+ """
40+
41+ def __init__ (self , limits ):
42+ # limits: iterable of (max_calls, period_seconds)
43+ self ._windows = [(n , p , deque ()) for n , p in limits ]
44+
45+ def acquire (self ):
46+ while True :
47+ now = time .monotonic ()
48+ wait = 0.0
49+ for n , p , hits in self ._windows :
50+ while hits and hits [0 ] <= now - p :
51+ hits .popleft ()
52+ if len (hits ) >= n :
53+ wait = max (wait , hits [0 ] + p - now )
54+ if wait <= 0 :
55+ break
56+ time .sleep (wait )
57+ now = time .monotonic ()
58+ for _ , _ , hits in self ._windows :
59+ hits .append (now )
60+
61+
2062_DB_TO_SPECIES = {
2163 "hsapiens_gene_ensembl" : "homo_sapiens" ,
2264 "mmusculus_gene_ensembl" : "mus_musculus" ,
@@ -41,11 +83,15 @@ class EnsemblRESTAdapter(ADBAdapter):
4183 :param str species: Species name (default: 'homo_sapiens')
4284 """
4385
44- def __init__ (self , server = 'https://rest.ensembl.org' , species = 'homo_sapiens' ):
86+ def __init__ (self , server = 'https://rest.ensembl.org' , species = 'homo_sapiens' ,
87+ max_requests_per_second = 13 , max_requests_per_hour = 50000 ,
88+ max_rate_limit_retries = 3 ):
4589 self ._server = server .rstrip ('/' )
4690 self ._species = species
91+ self ._max_rate_limit_retries = max_rate_limit_retries
4792
4893 self ._session = requests .Session ()
94+ # 429 is handled in _request (Retry-After); urllib3 covers 5xx + connection.
4995 retry = Retry (
5096 total = 3 ,
5197 status_forcelist = [500 , 502 , 503 , 504 ],
@@ -55,6 +101,11 @@ def __init__(self, server='https://rest.ensembl.org', species='homo_sapiens'):
55101 self ._session .mount ('https://' , HTTPAdapter (max_retries = retry ))
56102 self ._session .mount ('http://' , HTTPAdapter (max_retries = retry ))
57103
104+ self ._rate_limiter = _RateLimiter ([
105+ (max_requests_per_second , 1.0 ),
106+ (max_requests_per_hour , 3600.0 ),
107+ ])
108+
58109 self ._ids_cache = {}
59110 self ._sequence_cache = {}
60111 self ._gene_cache = {}
@@ -90,46 +141,61 @@ def _resolve_refseq_to_ensembl(self, refseq_id, expected_type, species):
90141 def _request (self , endpoint , params = None , content_type = 'application/json' ,
91142 method = 'GET' , data = None ):
92143 """
93- Make a rate-limit-aware request to the Ensembl REST API.
144+ Make a rate-limited request to the Ensembl REST API.
94145
95- Returns parsed JSON (dict/list) or raw text string depending on content_type.
96- Returns None on error.
146+ :return: parsed JSON (dict/list) or raw text for 2xx; None for a genuine
147+ 'not found' (HTTP 400/404).
148+ :raises EnsemblRateLimitError: 429 rate-limit retries exhausted.
149+ :raises EnsemblConnectionError: connection error or timeout.
150+ :raises EnsemblRESTError: other definitive HTTP failure (e.g. 5xx exhausted).
97151 """
98152 url = self ._server + endpoint
99153 headers = {"Content-Type" : content_type , "Accept" : content_type }
100154
101- for attempt in range (4 ): # 1 initial + 3 retries
155+ for _ in range (self ._max_rate_limit_retries + 1 ):
156+ self ._rate_limiter .acquire ()
102157 try :
103158 if method == 'POST' :
104- headers [ "Content-Type" ] = "application/json"
105- headers [ "Accept" ] = content_type
106- resp = self . _session . post ( url , headers = headers , json = data , timeout = 30 )
159+ resp = self . _session . post (
160+ url , headers = headers | { "Content-Type" : "application/json" },
161+ json = data , timeout = 30 )
107162 else :
108- resp = self ._session .get (url , headers = headers , params = params , timeout = 30 )
109-
110- # Handle rate limiting
111- if resp .status_code == 429 :
112- retry_after = float (resp .headers .get ("Retry-After" , 1.0 ))
113- logging .warning ("Rate limited by Ensembl REST API, sleeping %.1fs" , retry_after )
114- time .sleep (retry_after )
115- continue
163+ resp = self ._session .get (
164+ url , headers = headers , params = params , timeout = 30 )
165+ except (requests .exceptions .ConnectionError ,
166+ requests .exceptions .Timeout ) as e :
167+ raise EnsemblConnectionError (
168+ f"Ensembl REST request to { endpoint } failed: { e } " ) from e
169+ except requests .exceptions .RequestException as e :
170+ # Includes RetryError raised when urllib3 exhausts 5xx retries.
171+ raise EnsemblRESTError (
172+ f"Ensembl REST request to { endpoint } failed: { e } " ) from e
173+
174+ if resp .status_code == 429 :
175+ retry_after = float (resp .headers .get ("Retry-After" , 1.0 ))
176+ logging .warning (
177+ "Rate limited by Ensembl REST API, sleeping %.1fs" , retry_after )
178+ time .sleep (retry_after )
179+ continue
116180
117- if resp .status_code == 400 :
118- logging .warning ("Bad request to Ensembl REST API: %s" , endpoint )
119- return None
181+ if resp .status_code in (400 , 404 ):
182+ logging .warning ("Ensembl REST API: no entry for %s (HTTP %d)" ,
183+ endpoint , resp .status_code )
184+ return None
120185
121- resp .raise_for_status ()
186+ if not resp .ok :
187+ raise EnsemblRESTError (
188+ f"Ensembl REST API error { resp .status_code } for { endpoint } " )
122189
123- if content_type == 'text/plain' :
124- return resp .text
190+ if content_type == 'text/plain' :
191+ return resp .text
192+ try :
125193 return resp .json ()
194+ except ValueError :
195+ return None # empty/invalid 2xx body -> 'no data'
126196
127- except requests .exceptions .RequestException as e :
128- logging .warning ("Ensembl REST API request failed: %s" , e )
129- return None
130-
131- logging .warning ("Ensembl REST API rate limit retries exhausted for %s" , endpoint )
132- return None
197+ raise EnsemblRateLimitError (
198+ f"Ensembl REST API rate limit retries exhausted for { endpoint } " )
133199
134200 def get_product_sequence (self , product_id , ** kwargs ):
135201 """
0 commit comments