Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file.

### SDK

- **[feature]** OAuth token requests are now automatically retried (up to 3 times, with exponential backoff and jitter) when the authentication service is rate-limited (`HTTP 429`).
- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities.
- **[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.
- **[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.
Expand Down
1 change: 1 addition & 0 deletions sinch/core/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

class HTTPEndpoint(ABC):
ENDPOINT_URL = None
IS_RETRYABLE = False

@property
@abstractmethod
Expand Down
117 changes: 113 additions & 4 deletions sinch/core/ports/http_transport.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
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

Expand All @@ -26,6 +30,12 @@ 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
RETRY_AFTER_JITTER = 0.25

def __init__(self, sinch):
self.sinch = sinch
self._legacy_send = self._uses_legacy_send()
Expand Down Expand Up @@ -91,15 +101,114 @@ 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(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_request(request_data)
http_response = self._send_with_retries(endpoint, request_data)

return endpoint.handle_response(http_response)


def _send_with_retries(
self, endpoint: HTTPEndpoint, request_data: Optional[HttpRequest] = None
) -> HTTPResponse:
"""
Sends a request, retrying rate-limited responses (up to MAX_RETRIES,
with backoff) for endpoints that opt in via :attr:`HTTPEndpoint.IS_RETRYABLE`.

:param endpoint: The endpoint being called.
:type endpoint: HTTPEndpoint
: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
"""
num_retries = 0
while True:
if request_data is None:
http_response = self.send(endpoint)
else:
http_response = self.send_request(request_data)

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, endpoint: HTTPEndpoint, http_response: HTTPResponse, num_retries: int
) -> bool:
"""
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.
:type num_retries: int
: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

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, self.RETRY_AFTER_JITTER)

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 the Retry-After header 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.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:
Expand Down Expand Up @@ -215,12 +324,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, 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(endpoint)
http_response = self._send_with_retries(endpoint, request_data=None)

return endpoint.handle_response(http_response)

Expand Down
1 change: 1 addition & 0 deletions sinch/domains/authentication/endpoints/v1/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 162 additions & 1 deletion tests/unit/test_http_transport.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import random
import time
import pytest
from unittest.mock import Mock
from sinch.core.enums import HTTPAuthentication
Expand All @@ -13,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):
Expand Down Expand Up @@ -45,6 +47,7 @@ def handle_response(self, response: HTTPResponse):
)
return response

_Endpoint.IS_RETRYABLE = is_retryable
return _Endpoint()


Expand Down Expand Up @@ -92,6 +95,14 @@ def mock_sinch():
return sinch


@pytest.fixture
def no_sleep(mocker):
return mocker.patch.object(time, "sleep")

Comment thread
JPPortier marked this conversation as resolved.
@pytest.fixture
def no_jitter(mocker):
return mocker.patch.object(random, "uniform", return_value=0.0)

@pytest.fixture
def base_request():
return HttpRequest(
Expand Down Expand Up @@ -276,6 +287,156 @@ 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, no_jitter):
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, is_retryable=True)

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, no_jitter):
transport = HTTPTransportRequests(mock_sinch)
transport.http_session.request = Mock(return_value=self._rate_limited())
endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True)

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, no_jitter):
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, no_jitter):
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, no_jitter):
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, 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, no_jitter):
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(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(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(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:
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.0 + HTTPTransport.RETRY_AFTER_JITTER

def test_retry_after_jitter_is_within_bounds(self, mock_sinch):
transport = HTTPTransportRequests(mock_sinch)
response = HTTPResponse(status_code=429, headers={"Retry-After": "5"}, body={})

for _ in range(100): # run many times to exercise the random range
backoff = transport._compute_backoff(response, num_retries=0)
assert 5.0 <= backoff <= 5.0 + HTTPTransport.RETRY_AFTER_JITTER

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."""

Expand Down
Loading