Skip to content

Commit de6c5bd

Browse files
authored
Merge pull request #120 from KohlbacherLab/develop
Release 4.0.1
2 parents 4c82003 + 6e78357 commit de6c5bd

6 files changed

Lines changed: 274 additions & 31 deletions

File tree

.github/workflows/python-test-conda.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ jobs:
2424
auto-activate-base: false
2525
- name: Install epytope
2626
shell: bash -el {0}
27+
# --no-cache-dir avoids a corrupt runner pip cache making a present
28+
# package look unavailable ("from versions: none"); retries/timeout
29+
# harden against transient PyPI index hiccups.
2730
run: |
28-
pip install "numpy<2"
29-
pip install ".[dev]"
31+
pip install --no-cache-dir --retries 5 --timeout 60 "numpy<2"
32+
pip install --no-cache-dir --retries 5 --timeout 60 ".[dev]"
3033
- name: Install Test dependencies
3134
shell: bash -el {0}
3235
run: |

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ dist/*
99
.idea
1010
*.pyc
1111
epytope/tutorials/.ipynb_checkpoints/*
12+
13+
# Local agent/workspace files (never commit)
14+
CLAUDE.md
15+
.claude/
16+
docs/superpowers/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
44
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## v4.0.1 - 2026-06-11
7+
8+
### `Fixed`
9+
10+
- [#118](https://github.com/KohlbacherLab/epytope/pull/118) `EnsemblRESTAdapter` now raises `EnsemblRateLimitError` / `EnsemblConnectionError` on definitive transient failures (rate-limit retries exhausted, connection error, timeout) instead of silently returning `None`, which previously caused silently truncated variant→peptide results under rate limiting. Added a configurable client-side rate limiter (`max_requests_per_second`, `max_requests_per_hour`) and a unit-tested `_request` failure path. [#117](https://github.com/KohlbacherLab/epytope/issues/117)
11+
612
## v4.0.0 - 2026-04-01
713

814
### `Added`

epytope/IO/EnsemblRESTAdapter.py

Lines changed: 94 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import logging
1111
import time
12+
from collections import deque
1213

1314
import pandas as pd
1415
import requests
@@ -17,6 +18,47 @@
1718

1819
from 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
"""
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
from unittest import TestCase
2+
from unittest import mock
3+
4+
import requests
5+
6+
from epytope.IO.EnsemblRESTAdapter import (
7+
EnsemblRESTAdapter,
8+
EnsemblRESTError,
9+
EnsemblRateLimitError,
10+
EnsemblConnectionError,
11+
_RateLimiter,
12+
)
13+
14+
15+
class TestRateLimiter(TestCase):
16+
def test_blocks_when_window_exceeded(self):
17+
clock = {"t": 1000.0}
18+
sleeps = []
19+
20+
def fake_sleep(secs):
21+
sleeps.append(secs)
22+
clock["t"] += secs
23+
24+
limiter = _RateLimiter([(2, 10.0)])
25+
with mock.patch("time.monotonic",
26+
lambda: clock["t"]), \
27+
mock.patch("time.sleep", fake_sleep):
28+
limiter.acquire() # 1st call: fits
29+
limiter.acquire() # 2nd call: fits
30+
limiter.acquire() # 3rd call: window full -> must wait 10s
31+
32+
self.assertEqual(sleeps, [10.0])
33+
34+
def test_no_block_under_limit(self):
35+
clock = {"t": 500.0}
36+
sleeps = []
37+
limiter = _RateLimiter([(5, 1.0)])
38+
with mock.patch("time.monotonic",
39+
lambda: clock["t"]), \
40+
mock.patch("time.sleep",
41+
lambda s: sleeps.append(s)):
42+
for _ in range(5):
43+
limiter.acquire()
44+
self.assertEqual(sleeps, [])
45+
46+
47+
class FakeResponse:
48+
def __init__(self, status_code=200, json_data=None, text="", headers=None,
49+
json_error=False):
50+
self.status_code = status_code
51+
self._json = json_data
52+
self.text = text
53+
self.headers = headers or {}
54+
self._json_error = json_error
55+
56+
@property
57+
def ok(self):
58+
return self.status_code < 400
59+
60+
def json(self):
61+
if self._json_error: # mimic requests' JSONDecodeError (a ValueError) on empty body
62+
raise ValueError("No JSON could be decoded")
63+
return self._json
64+
65+
66+
class FakeSession:
67+
"""Returns queued FakeResponses (or raises queued exceptions) per call.
68+
69+
If more than one outcome remains it pops the next; the last outcome
70+
repeats indefinitely (so a single 429 means 'always 429').
71+
"""
72+
73+
def __init__(self, outcomes):
74+
self._outcomes = list(outcomes)
75+
self.calls = []
76+
77+
def _next(self, *args, **kwargs):
78+
self.calls.append((args, kwargs))
79+
outcome = self._outcomes.pop(0) if len(self._outcomes) > 1 else self._outcomes[0]
80+
if isinstance(outcome, Exception):
81+
raise outcome
82+
return outcome
83+
84+
def get(self, *args, **kwargs):
85+
return self._next(*args, **kwargs)
86+
87+
def post(self, *args, **kwargs):
88+
return self._next(*args, **kwargs)
89+
90+
91+
def _adapter_with(outcomes):
92+
adapter = EnsemblRESTAdapter()
93+
adapter._session = FakeSession(outcomes)
94+
return adapter
95+
96+
97+
class TestRequestFailureMapping(TestCase):
98+
def setUp(self):
99+
# 429 path sleeps on Retry-After; patch it so tests stay fast.
100+
self._sleep_patch = mock.patch(
101+
"time.sleep", lambda s: None)
102+
self._sleep_patch.start()
103+
104+
def tearDown(self):
105+
self._sleep_patch.stop()
106+
107+
def test_429_exhausted_raises_ratelimit(self):
108+
adapter = _adapter_with([FakeResponse(429, headers={"Retry-After": "0"})])
109+
with self.assertRaises(EnsemblRateLimitError):
110+
adapter._request("/lookup/id/ENST1")
111+
112+
def test_connection_error_raises_connection(self):
113+
adapter = _adapter_with([requests.exceptions.ConnectionError("boom")])
114+
with self.assertRaises(EnsemblConnectionError):
115+
adapter._request("/lookup/id/ENST1")
116+
117+
def test_timeout_raises_connection(self):
118+
adapter = _adapter_with([requests.exceptions.Timeout("slow")])
119+
with self.assertRaises(EnsemblConnectionError):
120+
adapter._request("/lookup/id/ENST1")
121+
122+
def test_retryerror_raises_base_rest_error(self):
123+
adapter = _adapter_with([requests.exceptions.RetryError("5xx exhausted")])
124+
with self.assertRaises(EnsemblRESTError) as ctx:
125+
adapter._request("/lookup/id/ENST1")
126+
self.assertNotIsInstance(
127+
ctx.exception, (EnsemblRateLimitError, EnsemblConnectionError))
128+
129+
def test_non_retryable_error_status_raises_base_rest_error(self):
130+
# 403 (non-2xx, non-429, non-404) -> base error, not a subtype
131+
adapter = _adapter_with([FakeResponse(403)])
132+
with self.assertRaises(EnsemblRESTError) as ctx:
133+
adapter._request("/lookup/id/FORBIDDEN")
134+
self.assertNotIsInstance(
135+
ctx.exception, (EnsemblRateLimitError, EnsemblConnectionError))
136+
137+
def test_200_unparseable_json_returns_none(self):
138+
adapter = _adapter_with([FakeResponse(200, json_error=True)])
139+
self.assertIsNone(adapter._request("/lookup/id/EMPTY"))
140+
141+
def test_400_returns_none(self):
142+
adapter = _adapter_with([FakeResponse(400)])
143+
self.assertIsNone(adapter._request("/lookup/id/BAD"))
144+
145+
def test_404_returns_none(self):
146+
adapter = _adapter_with([FakeResponse(404)])
147+
self.assertIsNone(adapter._request("/lookup/id/MISSING"))
148+
149+
def test_200_json_returns_parsed(self):
150+
adapter = _adapter_with([FakeResponse(200, json_data={"id": "ENST1"})])
151+
self.assertEqual(adapter._request("/lookup/id/ENST1"), {"id": "ENST1"})
152+
153+
def test_200_text_returns_text(self):
154+
adapter = _adapter_with([FakeResponse(200, text="MEEPQS")])
155+
self.assertEqual(
156+
adapter._request("/sequence/id/ENSP1", content_type="text/plain"), "MEEPQS")
157+
158+
def test_429_then_success(self):
159+
adapter = _adapter_with([
160+
FakeResponse(429, headers={"Retry-After": "0"}),
161+
FakeResponse(200, json_data={"id": "ENST1"}),
162+
])
163+
self.assertEqual(adapter._request("/lookup/id/ENST1"), {"id": "ENST1"})

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "epytope"
7-
version = "4.0.0"
7+
version = "4.0.1"
88
description = "A Framework for Epitope Detection and Vaccine Design"
99
readme = "README.md"
1010
license = "BSD-3-Clause"

0 commit comments

Comments
 (0)