From 9822d7a0abc4b66b9f3a7a349cd7c034f4a49ac3 Mon Sep 17 00:00:00 2001 From: tombch Date: Fri, 30 Jan 2026 19:54:38 +0000 Subject: [PATCH 1/5] Automatic retry logic --- onyx/core.py | 38 ++++++++++++++-- pyproject.toml | 4 +- tests/test_api.py | 109 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/onyx/core.py b/onyx/core.py index b0e5271..c8c5282 100644 --- a/onyx/core.py +++ b/onyx/core.py @@ -3,6 +3,7 @@ import inspect import requests from requests import HTTPError, RequestException +from requests.adapters import HTTPAdapter, Retry from typing import Any, Generator, List, Dict, TextIO, Optional, Union from .config import OnyxConfig from .field import OnyxField @@ -21,17 +22,48 @@ class OnyxClientBase: def __init__(self, config: OnyxConfig): self.config = config self._session = None - self._request_handler = requests.request + self._request_handler = self._default_request_handler def __enter__(self): - self._session = requests.Session() + self._session = self._get_session() self._request_handler = self._session.request return self def __exit__(self, type, value, traceback): if self._session: self._session.close() - self._request_handler = requests.request + self._session = None + self._request_handler = self._default_request_handler + + def _get_session(self) -> requests.Session: + session = requests.Session() + retry_strategy = Retry( + total=5, + backoff_factor=1, + status_forcelist=[ + 429, # Too Many Requests + 502, # Bad Gateway + 503, # Service Unavailable + 504, # Gateway Timeout + ], + allowed_methods=[ + "HEAD", + "GET", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + ], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + def _default_request_handler(self, method: str, **kwargs) -> requests.Response: + with self._get_session() as session: + return session.request(method, **kwargs) def _request(self, method: str, retries: int = 3, **kwargs) -> requests.Response: if not retries: diff --git a/pyproject.toml b/pyproject.toml index 2f1c861..0aa6318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,8 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["ruff", "pytest", "coverage"] -test = ["pytest", "coverage"] +dev = ["ruff", "pytest", "coverage", "responses"] +test = ["pytest", "coverage", "responses"] [project.scripts] onyx = "onyx.cli:main" diff --git a/tests/test_api.py b/tests/test_api.py index e8f73b6..ed44cf3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,7 +1,9 @@ import io import requests +import responses import pytest from unittest import TestCase, mock +from requests.adapters import HTTPAdapter from onyx import OnyxConfig, OnyxClient, exceptions, OnyxField, OnyxEndpoint @@ -1011,7 +1013,112 @@ def test_context_manager(self, mock_request): client._session.request, #  type: ignore ) - self.assertEqual(client._request_handler, requests.request) + self.assertEqual(client._session, None) + self.assertEqual(client._request_handler, client._default_request_handler) + + def test_retry_configured(self): + """ + Test that the retry adapter is configured correctly. + """ + + def test_config(session): + # Verify the session has retry adapters configured for both http and https + for scheme in ("https://", "http://"): + adapter = session.get_adapter(scheme) + assert isinstance(adapter, HTTPAdapter) + self.assertEqual(adapter.max_retries.total, 5) + self.assertEqual(adapter.max_retries.backoff_factor, 1) + + for status_code in [429, 502, 503, 504]: + self.assertIn(status_code, adapter.max_retries.status_forcelist) + + # Test within context manager + with OnyxClient(self.config) as client: + test_config(client._session) + + # Test outside context manager + # Capture the session being created + sessions = [] + + def _get_session(): + session = self.client._get_session() + sessions.append(session) + return session + + client = OnyxClient(self.config) + client._get_session = _get_session + with mock.patch.object( + requests.Session, "request", return_value=MockResponse(PROJECT_DATA) + ): + client.projects() + self.assertEqual(len(sessions), 1) + test_config(sessions[0]) + + @responses.activate + def test_retry(self): + """ + Test that retries occur on selected status codes. + """ + + url = OnyxEndpoint["projects"](DOMAIN) + status_codes = [429, 502, 503, 504] + + # Test within context manager + for status_code in status_codes: + # First two requests return badly, third returns 200 + responses.add(responses.GET, url, status=status_code) + responses.add(responses.GET, url, status=status_code) + responses.add(responses.GET, url, json=PROJECT_DATA, status=200) + + with OnyxClient(self.config) as client: + result = client.projects() + self.assertEqual(result, PROJECT_DATA["data"]) + + # Test outside context manager + for status_code in status_codes: + # First two requests return badly, third returns 200 + responses.add(responses.GET, url, status=status_code) + responses.add(responses.GET, url, status=status_code) + responses.add(responses.GET, url, json=PROJECT_DATA, status=200) + + result = OnyxClient(self.config).projects() + self.assertEqual(result, PROJECT_DATA["data"]) + + # 3 requests per status code * number of status codes * 2 (with/without context manager) + num_expected_calls = 3 * len(status_codes) * 2 + self.assertEqual(len(responses.calls), num_expected_calls) + + @responses.activate + def test_retry_exhausted_error(self): + """ + Test that an error is raised when all retries are exhausted. + """ + + url = OnyxEndpoint["projects"](DOMAIN) + status_codes = [429, 502, 503, 504] + + # Test within context manager + for status_code in status_codes: + # Return badly for all attempts (more than max retries) + for _ in range(10): + responses.add(responses.GET, url, status=status_code) + + with OnyxClient(self.config) as client: + with pytest.raises(exceptions.OnyxConnectionError): + client.projects() + + # Test outside context manager + for status_code in status_codes: + # Return badly for all attempts (more than max retries) + for _ in range(10): + responses.add(responses.GET, url, status=status_code) + + with pytest.raises(exceptions.OnyxConnectionError): + OnyxClient(self.config).projects() + + # (1 initial request per test + 5 retries) * number of status codes * 2 (with/without context manager) + num_expected_calls = (1 + 5) * len(status_codes) * 2 + self.assertEqual(len(responses.calls), num_expected_calls) @mock.patch("onyx.OnyxClient._request_handler", side_effect=mock_request) def test_connection_error(self, mock_request): From 3d6322045840426c750cf69afa8466514fdfebf8 Mon Sep 17 00:00:00 2001 From: tombch Date: Fri, 30 Jan 2026 20:00:18 +0000 Subject: [PATCH 2/5] Always set to None --- onyx/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onyx/core.py b/onyx/core.py index c8c5282..25eebd5 100644 --- a/onyx/core.py +++ b/onyx/core.py @@ -32,7 +32,7 @@ def __enter__(self): def __exit__(self, type, value, traceback): if self._session: self._session.close() - self._session = None + self._session = None self._request_handler = self._default_request_handler def _get_session(self) -> requests.Session: From 0608e269bd3920228c3ce9b8043c1d88817d02a1 Mon Sep 17 00:00:00 2001 From: tombch Date: Mon, 9 Feb 2026 14:46:48 +0000 Subject: [PATCH 3/5] Removed 429 too many requests from retry --- onyx/core.py | 24 +++++++++--------------- tests/test_api.py | 17 ++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/onyx/core.py b/onyx/core.py index 25eebd5..6933ff2 100644 --- a/onyx/core.py +++ b/onyx/core.py @@ -15,6 +15,13 @@ ) from .endpoints import OnyxEndpoint +RETRY_STATUS_CODES = [ + 502, # Bad Gateway + 503, # Service Unavailable + 504, # Gateway Timeout +] +RETRY_METHODS = ["HEAD", "GET", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] + class OnyxClientBase: __slots__ = "config", "_request_handler", "_session" @@ -40,21 +47,8 @@ def _get_session(self) -> requests.Session: retry_strategy = Retry( total=5, backoff_factor=1, - status_forcelist=[ - 429, # Too Many Requests - 502, # Bad Gateway - 503, # Service Unavailable - 504, # Gateway Timeout - ], - allowed_methods=[ - "HEAD", - "GET", - "OPTIONS", - "POST", - "PUT", - "PATCH", - "DELETE", - ], + status_forcelist=RETRY_STATUS_CODES, + allowed_methods=RETRY_METHODS, ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("https://", adapter) diff --git a/tests/test_api.py b/tests/test_api.py index ed44cf3..7239d0b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,6 +5,7 @@ from unittest import TestCase, mock from requests.adapters import HTTPAdapter from onyx import OnyxConfig, OnyxClient, exceptions, OnyxField, OnyxEndpoint +from onyx.core import RETRY_STATUS_CODES DOMAIN = "https://onyx.domain" @@ -1029,7 +1030,7 @@ def test_config(session): self.assertEqual(adapter.max_retries.total, 5) self.assertEqual(adapter.max_retries.backoff_factor, 1) - for status_code in [429, 502, 503, 504]: + for status_code in RETRY_STATUS_CODES: self.assertIn(status_code, adapter.max_retries.status_forcelist) # Test within context manager @@ -1061,10 +1062,9 @@ def test_retry(self): """ url = OnyxEndpoint["projects"](DOMAIN) - status_codes = [429, 502, 503, 504] # Test within context manager - for status_code in status_codes: + for status_code in RETRY_STATUS_CODES: # First two requests return badly, third returns 200 responses.add(responses.GET, url, status=status_code) responses.add(responses.GET, url, status=status_code) @@ -1075,7 +1075,7 @@ def test_retry(self): self.assertEqual(result, PROJECT_DATA["data"]) # Test outside context manager - for status_code in status_codes: + for status_code in RETRY_STATUS_CODES: # First two requests return badly, third returns 200 responses.add(responses.GET, url, status=status_code) responses.add(responses.GET, url, status=status_code) @@ -1085,7 +1085,7 @@ def test_retry(self): self.assertEqual(result, PROJECT_DATA["data"]) # 3 requests per status code * number of status codes * 2 (with/without context manager) - num_expected_calls = 3 * len(status_codes) * 2 + num_expected_calls = 3 * len(RETRY_STATUS_CODES) * 2 self.assertEqual(len(responses.calls), num_expected_calls) @responses.activate @@ -1095,10 +1095,9 @@ def test_retry_exhausted_error(self): """ url = OnyxEndpoint["projects"](DOMAIN) - status_codes = [429, 502, 503, 504] # Test within context manager - for status_code in status_codes: + for status_code in RETRY_STATUS_CODES: # Return badly for all attempts (more than max retries) for _ in range(10): responses.add(responses.GET, url, status=status_code) @@ -1108,7 +1107,7 @@ def test_retry_exhausted_error(self): client.projects() # Test outside context manager - for status_code in status_codes: + for status_code in RETRY_STATUS_CODES: # Return badly for all attempts (more than max retries) for _ in range(10): responses.add(responses.GET, url, status=status_code) @@ -1117,7 +1116,7 @@ def test_retry_exhausted_error(self): OnyxClient(self.config).projects() # (1 initial request per test + 5 retries) * number of status codes * 2 (with/without context manager) - num_expected_calls = (1 + 5) * len(status_codes) * 2 + num_expected_calls = (1 + 5) * len(RETRY_STATUS_CODES) * 2 self.assertEqual(len(responses.calls), num_expected_calls) @mock.patch("onyx.OnyxClient._request_handler", side_effect=mock_request) From 4864caa4c533be527b4ad401329e61706d3e8569 Mon Sep 17 00:00:00 2001 From: tombch Date: Wed, 11 Feb 2026 12:28:38 +0000 Subject: [PATCH 4/5] Raising OnyxServerError for 502-504 --- onyx/core.py | 1 + tests/test_api.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/onyx/core.py b/onyx/core.py index 6933ff2..3718a76 100644 --- a/onyx/core.py +++ b/onyx/core.py @@ -49,6 +49,7 @@ def _get_session(self) -> requests.Session: backoff_factor=1, status_forcelist=RETRY_STATUS_CODES, allowed_methods=RETRY_METHODS, + raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("https://", adapter) diff --git a/tests/test_api.py b/tests/test_api.py index 7239d0b..b7be1ed 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1103,7 +1103,7 @@ def test_retry_exhausted_error(self): responses.add(responses.GET, url, status=status_code) with OnyxClient(self.config) as client: - with pytest.raises(exceptions.OnyxConnectionError): + with pytest.raises(exceptions.OnyxServerError): client.projects() # Test outside context manager @@ -1112,7 +1112,7 @@ def test_retry_exhausted_error(self): for _ in range(10): responses.add(responses.GET, url, status=status_code) - with pytest.raises(exceptions.OnyxConnectionError): + with pytest.raises(exceptions.OnyxServerError): OnyxClient(self.config).projects() # (1 initial request per test + 5 retries) * number of status codes * 2 (with/without context manager) From 8174661a0f842bb6a64825938411c677aa74b4bc Mon Sep 17 00:00:00 2001 From: tombch Date: Wed, 11 Feb 2026 14:25:35 +0000 Subject: [PATCH 5/5] Version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0aa6318..273b4a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "climb-onyx-client" -version = "4.9.0" +version = "4.9.1" description = "CLI and Python library for Onyx" readme = "README.md" requires-python = ">=3.9"