From 76bca5e61a70ef7fcc1103114539c7261479a815 Mon Sep 17 00:00:00 2001 From: Elarwei Date: Thu, 2 Jul 2026 22:44:11 +0800 Subject: [PATCH 1/2] feat(utils): retry transient errors in rest_query/post_query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensembl REST and other upstream services occasionally return transient 5xx (500/502/503/504) or 429 responses, or drop the connection under load. Every Ensembl-backed module (seq, ref, search, info, blat, and enrichr with ensembl=True) routes through rest_query/post_query, so a single transient blip currently fails the whole call. Add a small _query_with_retry helper and route both functions through it: - Retries on 429/5xx and on connection/timeout errors, with linear backoff (2s, 4s, 6s). - Honors a Retry-After header when the server sends one (capped at 30s). - Does NOT retry 4xx client errors (e.g. 404) — those are returned to the caller unchanged, preserving existing error handling. - After exhausting retries, returns the last response (or re-raises the last connection error) so callers behave exactly as before. Add offline unit tests (TestQueryRetry) covering success-after-retry, connection-error retry, no-retry on 404, retry exhaustion, and Retry-After handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- gget/utils.py | 42 +++++++++++++++++++++++++++-- tests/test_utils.py | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/gget/utils.py b/gget/utils.py index 040730e39..d3e9897d0 100644 --- a/gget/utils.py +++ b/gget/utils.py @@ -742,6 +742,44 @@ def wrap_cols_func(df: pd.DataFrame, cols: list[str]) -> Any: return display(HTML(df.to_html().replace("\\n", "
"))) +# Retry the shared REST query helpers on transient upstream errors. Ensembl's REST API (which +# many gget modules depend on) returns 429 (rate limit, with a Retry-After header) or 5xx under +# load; a short backoff keeps a transient hiccup from failing the whole call. +_QUERY_RETRIES = 4 +_QUERY_BACKOFF_SEC = 2 +_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504}) + + +def _query_with_retry(method: str, url: str, **kwargs: Any) -> requests.Response: + """GET/POST with retry on transient errors (429/5xx and connection/timeout errors). + + Honors a Retry-After header when present. Non-retryable client errors (e.g. 404) and the + final response after exhausting retries are returned to the caller to handle as before. + """ + kwargs.setdefault("timeout", DEFAULT_REQUESTS_TIMEOUT) + last_exc: Exception | None = None + response: requests.Response | None = None + for attempt in range(1, _QUERY_RETRIES + 1): + try: + response = requests.request(method, url, **kwargs) + except requests.RequestException as e: + last_exc = e + response = None + else: + if response.ok or response.status_code not in _RETRYABLE_STATUS: + return response + if attempt < _QUERY_RETRIES: + wait = _QUERY_BACKOFF_SEC * attempt + if response is not None: + retry_after = response.headers.get("Retry-After", "") + if retry_after.isdigit(): + wait = min(int(retry_after), 30) + time.sleep(wait) + if response is not None: + return response + raise requests.RequestException(f"Request to {url} failed after {_QUERY_RETRIES} attempts: {last_exc}") + + def rest_query(server: str, query: str, content_type: str) -> Any: """Function to perform a REST API query. @@ -752,7 +790,7 @@ def rest_query(server: str, query: str, content_type: str) -> Any: Returns server output. """ - r = requests.get(server + query, headers={"Content-Type": content_type}) + r = _query_with_retry("GET", server + query, headers={"Content-Type": content_type}) if not r.ok: raise RuntimeError( @@ -774,7 +812,7 @@ def post_query(server: str, endpoint: str, query: Any) -> Any: :return: server output """ - r = requests.post(server + endpoint, json=query, headers={"Content-Type": "application/json"}) + r = _query_with_retry("POST", server + endpoint, json=query, headers={"Content-Type": "application/json"}) if not r.ok: raise RuntimeError( diff --git a/tests/test_utils.py b/tests/test_utils.py index 1eda8078d..191a407e9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,8 @@ import unittest +from unittest.mock import Mock, patch +import gget.utils as gget_utils +import requests from gget.constants import ENSEMBL_FTP_URL_NV, ENSEMBL_REST_API, UNIPROT_REST_API from gget.utils import ( aa_colors, @@ -182,3 +185,64 @@ def test_ref_iv_species_options(self): def test_ref_species_options_bad_type(self): with self.assertRaises(RuntimeError): ref_species_options("gtf", release=2000) + + +def _resp(status_code, headers=None): + """Minimal mock requests.Response with .status_code/.ok/.headers.""" + m = Mock() + m.status_code = status_code + m.ok = 200 <= status_code < 300 + m.headers = headers or {} + return m + + +class TestQueryRetry(unittest.TestCase): + """Network-free tests for the transient-error retry in the REST query helpers.""" + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_retries_on_503_then_succeeds(self, mock_request, _sleep): + mock_request.side_effect = [_resp(503), _resp(503), _resp(200)] + r = gget_utils._query_with_retry("GET", "http://x") + self.assertEqual(r.status_code, 200) + self.assertEqual(mock_request.call_count, 3) + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_retries_on_connection_error_then_succeeds(self, mock_request, _sleep): + mock_request.side_effect = [requests.ConnectionError("boom"), _resp(200)] + r = gget_utils._query_with_retry("GET", "http://x") + self.assertEqual(r.status_code, 200) + self.assertEqual(mock_request.call_count, 2) + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_no_retry_on_client_error(self, mock_request, _sleep): + # A 404 is a real client error, not transient -> returned immediately (caller handles it). + mock_request.return_value = _resp(404) + r = gget_utils._query_with_retry("GET", "http://x") + self.assertEqual(r.status_code, 404) + self.assertEqual(mock_request.call_count, 1) + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_exhausts_retries_on_persistent_503(self, mock_request, _sleep): + mock_request.return_value = _resp(503) + r = gget_utils._query_with_retry("GET", "http://x") + # After exhausting retries the last (503) response is returned so the caller raises as before. + self.assertEqual(r.status_code, 503) + self.assertEqual(mock_request.call_count, gget_utils._QUERY_RETRIES) + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_persistent_connection_error_raises(self, mock_request, _sleep): + mock_request.side_effect = requests.ConnectionError("down") + with self.assertRaises(requests.RequestException): + gget_utils._query_with_retry("GET", "http://x") + + @patch("gget.utils.time.sleep") + @patch("gget.utils.requests.request") + def test_retry_after_header_used(self, mock_request, mock_sleep): + mock_request.side_effect = [_resp(429, headers={"Retry-After": "7"}), _resp(200)] + gget_utils._query_with_retry("GET", "http://x") + mock_sleep.assert_called_once_with(7) From 85d2124bd125296283c7bf93dc8b1ad39eba2c0d Mon Sep 17 00:00:00 2001 From: Elarwei Date: Fri, 3 Jul 2026 23:18:32 +0800 Subject: [PATCH 2/2] test: report the 25 slowest tests via pytest --durations Add `--durations=25` to pytest addopts so every CI run prints the slowest tests at the end of the log. This makes it easy to spot which (mostly network-bound) tests dominate CI wall-clock time, complementing the request-retry changes in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f36091db5..a1ed8aed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,7 +145,7 @@ show_error_codes = true [tool.pytest] ini_options.testpaths = [ "tests" ] -ini_options.addopts = [ "-ra" ] +ini_options.addopts = [ "-ra", "--durations=25" ] [tool.coverage] run.omit = [