From f7596033ee0c21643a275c4815d5d401fc1d2b9d Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 17 Jun 2026 16:12:04 +0200 Subject: [PATCH 1/6] feature(core): Implement HTTP 429 retry logic with backoff in HTTPTransport --- sinch/core/ports/http_transport.py | 112 +++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 6e97cf08..7dd7f46f 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,7 +1,11 @@ +import random +import time import warnings from abc import ABC +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from platform import python_version -from typing import Optional +from typing import Optional, Union, overload from requests import Response from sinch.core.endpoint import HTTPEndpoint @@ -26,6 +30,11 @@ class HTTPTransport(ABC): ``send`` override path will be removed in 3.0. """ + MAX_RETRIES = 3 + RETRYABLE_STATUS_CODES = frozenset({429}) + BACKOFF_BASE_SECONDS = 1.0 + BACKOFF_GROWTH = 4 + def __init__(self, sinch): self.sinch = sinch self._legacy_send = self._uses_legacy_send() @@ -91,15 +100,108 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: request_data = self.prepare_request(endpoint) request_data = self.authenticate(endpoint, request_data) - http_response = self.send_request(request_data) + + http_response = self._send_with_retries(request_data) if self._should_refresh_token(endpoint, http_response): used_token = self._get_bearer_token_from_request(request_data) new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token) self._set_bearer_token(request_data, new_token.access_token) - http_response = self.send_request(request_data) + http_response = self._send_with_retries(request_data) return endpoint.handle_response(http_response) + + + def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: + """ + Sends a request, retrying rate-limited (HTTP 429) responses up to + MAX_RETRIES times with backoff between attempts. + + :param request_data: The prepared request to send, or, on the legacy + ``send`` path, the endpoint to call. + :type request_data: Union[HttpRequest, HTTPEndpoint] + :returns: The HTTP response from the last attempt. + :rtype: HTTPResponse + """ + num_retries = 0 + while True: + if isinstance(request_data, HTTPEndpoint): + http_response = self.send(request_data) + else: + http_response = self.send_request(request_data) + + if self._should_retry(http_response, num_retries): + time.sleep(self._compute_backoff(http_response, num_retries)) + num_retries += 1 + continue + else: + return http_response + + def _should_retry(self, http_response: HTTPResponse, num_retries: int) -> bool: + """ + Returns True when the response is a transient error and + retries remain. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: Whether the request should be retried. + :rtype: bool + """ + if num_retries >= self.MAX_RETRIES: + return False + return http_response.status_code in self.RETRYABLE_STATUS_CODES + + def _compute_backoff(self, http_response: HTTPResponse, num_retries: int) -> float: + """ + Computes how long to wait before the next retry. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: The delay in seconds. + :rtype: float + """ + + headers = {key.lower(): value for key, value in http_response.headers.items()} + retry_after_seconds = self._parse_retry_after(headers.get("retry-after")) + if retry_after_seconds is not None: + return retry_after_seconds + random.uniform(0, 0.25) + + max_delay = self.BACKOFF_BASE_SECONDS * (self.BACKOFF_GROWTH ** num_retries) + return random.uniform(0, max_delay) + + @staticmethod + def _parse_retry_after(value: Optional[str]) -> Optional[float]: + """ + Parses a ``Retry-After`` header (delta-seconds or HTTP-date) into a + delay in seconds, or None if absent/unparseable. + + :param value: The raw header value. + :type value: Optional[str] + :returns: The delay in seconds, or None if absent/invalid. + :rtype: Optional[float] + """ + if not value: + return None + + try: + seconds = float(value) + return seconds if seconds >= 0 else None + except ValueError: + pass + + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if retry_at is None: + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) def authenticate(self, endpoint: HTTPEndpoint, request_data: HttpRequest) -> HttpRequest: @@ -215,12 +317,12 @@ def _legacy_request(self, endpoint: HTTPEndpoint) -> HTTPResponse: :rtype: HTTPResponse """ token_before = self.sinch.configuration.token_manager.token - http_response = self.send(endpoint) + http_response = self._send_with_retries(endpoint) if self._should_refresh_token(endpoint, http_response): used_token = token_before.access_token if token_before else None self.sinch.configuration.token_manager.refresh_auth_token(used_token) - http_response = self.send(endpoint) + http_response = self._send_with_retries(endpoint) return endpoint.handle_response(http_response) From 0c47d31cf220495621f4b6a336432b987d06d8f4 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 18 Jun 2026 10:08:57 +0200 Subject: [PATCH 2/6] feature(test): Add tests for HTTP 429 retry logic with backoff handling --- .../core/adapters/requests_http_transport.py | 9 +- sinch/core/ports/http_transport.py | 9 +- tests/unit/test_http_transport.py | 129 ++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 926791e8..a61f1743 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -21,8 +21,9 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: :rtype: HTTPResponse """ self.sinch.configuration.logger.debug( - "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", - request_data.http_method, request_data.headers, request_data.request_body, request_data.url + "Sync HTTP request %s %s", + request_data.http_method, + request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -37,8 +38,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: response_body = self.deserialize_json_response(response) self.sinch.configuration.logger.debug( - "Sync HTTP response %s with headers: %s and body: %s from URL: %s", - response.status_code, response.headers, response_body, request_data.url + "Sync HTTP response %s", + response.status_code ) return HTTPResponse( diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 7dd7f46f..ca27ee60 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from platform import python_version -from typing import Optional, Union, overload +from typing import Optional, Union from requests import Response from sinch.core.endpoint import HTTPEndpoint @@ -114,7 +114,7 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: """ - Sends a request, retrying rate-limited (HTTP 429) responses up to + Sends a request, retrying rate-limited responses up to MAX_RETRIES times with backoff between attempts. :param request_data: The prepared request to send, or, on the legacy @@ -133,7 +133,6 @@ def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> if self._should_retry(http_response, num_retries): time.sleep(self._compute_backoff(http_response, num_retries)) num_retries += 1 - continue else: return http_response @@ -176,7 +175,7 @@ def _compute_backoff(self, http_response: HTTPResponse, num_retries: int) -> flo @staticmethod def _parse_retry_after(value: Optional[str]) -> Optional[float]: """ - Parses a ``Retry-After`` header (delta-seconds or HTTP-date) into a + Parses the Retry-After header into a delay in seconds, or None if absent/unparseable. :param value: The raw header value. @@ -197,8 +196,6 @@ def _parse_retry_after(value: Optional[str]) -> Optional[float]: retry_at = parsedate_to_datetime(value) except (TypeError, ValueError): return None - if retry_at is None: - return None if retry_at.tzinfo is None: retry_at = retry_at.replace(tzinfo=timezone.utc) return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index 2636e9ce..cca39c5c 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -1,4 +1,6 @@ import json +import random +import time import pytest from unittest.mock import Mock from sinch.core.enums import HTTPAuthentication @@ -92,6 +94,12 @@ def mock_sinch(): return sinch +@pytest.fixture +def no_sleep(mocker): + mocker.patch.object(random, "uniform", return_value=0.0) + return mocker.patch.object(time, "sleep") + + @pytest.fixture def base_request(): return HttpRequest( @@ -276,6 +284,127 @@ def test_no_refresh_on_401_without_www_authenticate(self, mock_sinch): token_manager.refresh_auth_token.assert_not_called() +class TestRetryWithBackoff: + """Tests for the automatic retry-with-backoff on rate-limited (429) responses.""" + + @staticmethod + def _rate_limited(headers=None): + return _requests_response(429, body={"error": "rate limited"}, headers=headers) + + def test_retries_on_429_then_succeeds(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(), + self._rate_limited(), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 3 + assert no_sleep.call_count == 2 + + def test_gives_up_and_returns_last_response_after_max_retries(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=self._rate_limited()) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 429 + assert transport.http_session.request.call_count == HTTPTransport.MAX_RETRIES + 1 + assert no_sleep.call_count == HTTPTransport.MAX_RETRIES + + def test_no_retry_on_success(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(200, body={"ok": True})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_no_retry_on_non_retryable_status(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(400, body={"error": "bad"})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 400 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_honors_retry_after_header(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(headers={"Retry-After": "7"}), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + transport.request(endpoint) + + no_sleep.assert_called_once_with(7.0) + + +class TestShouldRetry: + def test_retries_429_while_attempts_remain(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is True + + def test_stops_when_max_retries_reached(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=HTTPTransport.MAX_RETRIES) is False + + def test_does_not_retry_non_retryable_status(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=200, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is False + + +class TestComputeBackoff: + def test_uses_retry_after_header_when_present(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={"Retry-After": "5"}, body={}) + + backoff = transport._compute_backoff(response, num_retries=0) + + assert 5.0 <= backoff < 5.25 + + def test_exponential_growth_when_no_header(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert 0.0 <= transport._compute_backoff(response, num_retries=0) <= 1.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=1) <= 4.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=2) <= 16.0 + + +class TestParseRetryAfter: + @pytest.mark.parametrize("value,expected", [ + ("5", 5.0), + ("0", 0.0), + ("-3", None), + ("abc", None), + ("", None), + (None, None), + ("Wed, 21 Oct 2015 07:28:00 GMT", 0.0), + ("Wed, 21 Oct 2015 07:28:00", 0.0), + ]) + def test_parse_retry_after(self, value, expected): + assert HTTPTransport._parse_retry_after(value) == expected + + class _LegacyTransport(HTTPTransport): """A pre-2.1 transport that overrides the deprecated ``send(endpoint)`` hook.""" From 3d383b263fc30b35d7eada6b5b74151cb1ede01b Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 18 Jun 2026 10:21:25 +0200 Subject: [PATCH 3/6] chore: update changelog --- CHANGELOG.md | 1 + sinch/core/adapters/requests_http_transport.py | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5982e2b9..1a467864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK +- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#158). - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). - **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). - **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index a61f1743..926791e8 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -21,9 +21,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: :rtype: HTTPResponse """ self.sinch.configuration.logger.debug( - "Sync HTTP request %s %s", - request_data.http_method, - request_data.url + "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", + request_data.http_method, request_data.headers, request_data.request_body, request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -38,8 +37,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: response_body = self.deserialize_json_response(response) self.sinch.configuration.logger.debug( - "Sync HTTP response %s", - response.status_code + "Sync HTTP response %s with headers: %s and body: %s from URL: %s", + response.status_code, response.headers, response_body, request_data.url ) return HTTPResponse( From 548d7302f9372a16a09861dab0e9b1b768e45d1c Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 18 Jun 2026 10:28:01 +0200 Subject: [PATCH 4/6] Fix typo in CHANGELOG for HTTPTransport feature --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a467864..0b97a906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK -- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#158). +- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#159). - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). - **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). - **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). From 7c21fe0b89701c25f4a97e068b9dfbf08f389452 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 19 Jun 2026 15:29:19 +0200 Subject: [PATCH 5/6] feature(core): add retry configuration to endpoint --- CHANGELOG.md | 2 +- sinch/core/endpoint.py | 1 + sinch/core/ports/http_transport.py | 44 ++++++++++++------- .../authentication/endpoints/v1/oauth.py | 1 + tests/unit/test_http_transport.py | 36 ++++++++++++--- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b97a906..cabf3897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK -- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#159). +- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff for endpoints that opt in via the `HTTPEndpoint.IS_RETRYABLE` flag (`False` by default). When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#159). - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). - **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). - **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). diff --git a/sinch/core/endpoint.py b/sinch/core/endpoint.py index 30b4b6f7..bd498d53 100644 --- a/sinch/core/endpoint.py +++ b/sinch/core/endpoint.py @@ -4,6 +4,7 @@ class HTTPEndpoint(ABC): ENDPOINT_URL = None + IS_RETRYABLE = False @property @abstractmethod diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index ca27ee60..9342d820 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from platform import python_version -from typing import Optional, Union +from typing import Optional from requests import Response from sinch.core.endpoint import HTTPEndpoint @@ -101,46 +101,58 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: request_data = self.prepare_request(endpoint) request_data = self.authenticate(endpoint, request_data) - http_response = self._send_with_retries(request_data) + http_response = self._send_with_retries(endpoint, request_data) if self._should_refresh_token(endpoint, http_response): used_token = self._get_bearer_token_from_request(request_data) new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token) self._set_bearer_token(request_data, new_token.access_token) - http_response = self._send_with_retries(request_data) + http_response = self._send_with_retries(endpoint, request_data) return endpoint.handle_response(http_response) - def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: + def _send_with_retries( + self, endpoint: HTTPEndpoint, request_data: Optional[HttpRequest] = None + ) -> HTTPResponse: """ Sends a request, retrying rate-limited responses up to MAX_RETRIES times with backoff between attempts. - :param request_data: The prepared request to send, or, on the legacy - ``send`` path, the endpoint to call. - :type request_data: Union[HttpRequest, HTTPEndpoint] + Retries are only attempted for endpoints that opt in via + :attr:`HTTPEndpoint.IS_RETRYABLE`. + + :param endpoint: The endpoint being called, whose ``is_retryable`` flag + gates whether retries are attempted. + :type endpoint: HTTPEndpoint + :param request_data: The prepared request to send. ``None`` on the legacy + ``send`` path, where the endpoint is sent directly. + :type request_data: Optional[HttpRequest] :returns: The HTTP response from the last attempt. :rtype: HTTPResponse """ num_retries = 0 while True: - if isinstance(request_data, HTTPEndpoint): - http_response = self.send(request_data) + if request_data is None: + http_response = self.send(endpoint) else: http_response = self.send_request(request_data) - if self._should_retry(http_response, num_retries): + if self._should_retry(endpoint, http_response, num_retries): time.sleep(self._compute_backoff(http_response, num_retries)) num_retries += 1 else: return http_response - def _should_retry(self, http_response: HTTPResponse, num_retries: int) -> bool: + def _should_retry( + self, endpoint: HTTPEndpoint, http_response: HTTPResponse, num_retries: int + ) -> bool: """ - Returns True when the response is a transient error and - retries remain. + Returns True when the endpoint opts into retries, the response is a + transient error and retries remain. + :param endpoint: The endpoint being called. + :type endpoint: HTTPEndpoint :param http_response: The response received. :type http_response: HTTPResponse :param num_retries: Number of retries already performed. @@ -148,6 +160,8 @@ def _should_retry(self, http_response: HTTPResponse, num_retries: int) -> bool: :returns: Whether the request should be retried. :rtype: bool """ + if not endpoint.IS_RETRYABLE: + return False if num_retries >= self.MAX_RETRIES: return False return http_response.status_code in self.RETRYABLE_STATUS_CODES @@ -314,12 +328,12 @@ def _legacy_request(self, endpoint: HTTPEndpoint) -> HTTPResponse: :rtype: HTTPResponse """ token_before = self.sinch.configuration.token_manager.token - http_response = self._send_with_retries(endpoint) + http_response = self._send_with_retries(endpoint, request_data=None) if self._should_refresh_token(endpoint, http_response): used_token = token_before.access_token if token_before else None self.sinch.configuration.token_manager.refresh_auth_token(used_token) - http_response = self._send_with_retries(endpoint) + http_response = self._send_with_retries(endpoint, request_data=None) return endpoint.handle_response(http_response) diff --git a/sinch/domains/authentication/endpoints/v1/oauth.py b/sinch/domains/authentication/endpoints/v1/oauth.py index b8b867c5..f45d5602 100644 --- a/sinch/domains/authentication/endpoints/v1/oauth.py +++ b/sinch/domains/authentication/endpoints/v1/oauth.py @@ -9,6 +9,7 @@ class OAuthEndpoint(HTTPEndpoint): ENDPOINT_URL = "{origin}/oauth2/token" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.BASIC.value + IS_RETRYABLE = True def __init__(self): pass diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index cca39c5c..2a39aa42 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -15,7 +15,7 @@ # Mock classes and fixtures -def _make_mock_endpoint(auth_type, error_on_4xx=False): +def _make_mock_endpoint(auth_type, error_on_4xx=False, is_retryable=False): """Create a MockEndpoint that satisfies the abstract property contract.""" class _Endpoint(HTTPEndpoint): @@ -47,6 +47,7 @@ def handle_response(self, response: HTTPResponse): ) return response + _Endpoint.IS_RETRYABLE = is_retryable return _Endpoint() @@ -298,7 +299,7 @@ def test_retries_on_429_then_succeeds(self, mock_sinch, no_sleep): self._rate_limited(), _requests_response(200, body={"ok": True}), ]) - endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) result = transport.request(endpoint) @@ -309,7 +310,7 @@ def test_retries_on_429_then_succeeds(self, mock_sinch, no_sleep): def test_gives_up_and_returns_last_response_after_max_retries(self, mock_sinch, no_sleep): transport = HTTPTransportRequests(mock_sinch) transport.http_session.request = Mock(return_value=self._rate_limited()) - endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) result = transport.request(endpoint) @@ -345,31 +346,52 @@ def test_honors_retry_after_header(self, mock_sinch, no_sleep): self._rate_limited(headers={"Retry-After": "7"}), _requests_response(200, body={"ok": True}), ]) - endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) transport.request(endpoint) no_sleep.assert_called_once_with(7.0) + def test_no_retry_when_endpoint_not_retryable(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=self._rate_limited()) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=False) + + result = transport.request(endpoint) + + assert result.status_code == 429 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + class TestShouldRetry: def test_retries_429_while_attempts_remain(self, mock_sinch): transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) response = HTTPResponse(status_code=429, headers={}, body={}) - assert transport._should_retry(response, num_retries=0) is True + assert transport._should_retry(endpoint, response, num_retries=0) is True def test_stops_when_max_retries_reached(self, mock_sinch): transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) response = HTTPResponse(status_code=429, headers={}, body={}) - assert transport._should_retry(response, num_retries=HTTPTransport.MAX_RETRIES) is False + assert transport._should_retry(endpoint, response, num_retries=HTTPTransport.MAX_RETRIES) is False def test_does_not_retry_non_retryable_status(self, mock_sinch): transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True) response = HTTPResponse(status_code=200, headers={}, body={}) - assert transport._should_retry(response, num_retries=0) is False + assert transport._should_retry(endpoint, response, num_retries=0) is False + + def test_does_not_retry_when_endpoint_not_retryable(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=False) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(endpoint, response, num_retries=0) is False class TestComputeBackoff: From bc3d9348af85ef5740beec74430271a1ceac8c47 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 19 Jun 2026 15:38:13 +0200 Subject: [PATCH 6/6] feature(core): update retry logic documentation for HTTPTransport --- sinch/core/ports/http_transport.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 9342d820..f61b05d0 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -116,17 +116,12 @@ def _send_with_retries( self, endpoint: HTTPEndpoint, request_data: Optional[HttpRequest] = None ) -> HTTPResponse: """ - Sends a request, retrying rate-limited responses up to - MAX_RETRIES times with backoff between attempts. + Sends a request, retrying rate-limited responses (up to MAX_RETRIES, + with backoff) for endpoints that opt in via :attr:`HTTPEndpoint.IS_RETRYABLE`. - Retries are only attempted for endpoints that opt in via - :attr:`HTTPEndpoint.IS_RETRYABLE`. - - :param endpoint: The endpoint being called, whose ``is_retryable`` flag - gates whether retries are attempted. + :param endpoint: The endpoint being called. :type endpoint: HTTPEndpoint - :param request_data: The prepared request to send. ``None`` on the legacy - ``send`` path, where the endpoint is sent directly. + :param request_data: The prepared request, or ``None`` on the legacy ``send`` path. :type request_data: Optional[HttpRequest] :returns: The HTTP response from the last attempt. :rtype: HTTPResponse