From b52fdb0867d5d6319890bc4c4a92fdc16b3bb476 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Wed, 30 Sep 2026 13:19:12 -0600 Subject: [PATCH 1/2] Honor Retry-After on 5xx responses, not just 429 A 5xx carrying Retry-After was ignored: the client retried on its default backoff instead of waiting the requested time, unlike 429. Give ServiceError a retry_after_seconds slot (mirroring TooManyRequests) and pass the header from the 5xx branch of interpret_b2_error; b2http already honors a non-None value. --- b2sdk/_internal/exception.py | 11 ++++++++++- changelog.d/606.fixed.md | 1 + test/unit/test_exception.py | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 changelog.d/606.fixed.md diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index aef410f37..d692a778c 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -433,6 +433,12 @@ class ServiceError(TransientErrorMixin, B2Error): Used for HTTP status codes 500 through 599. """ + def __init__(self, *args, retry_after_seconds=None): + super().__init__(*args) + + if retry_after_seconds is not None: + self.retry_after_seconds = int(retry_after_seconds) + class CapExceeded(B2Error): def __str__(self): @@ -753,5 +759,8 @@ def interpret_b2_error( elif status == 429: return TooManyRequests(retry_after_seconds=response_headers.get('retry-after')) elif 500 <= status < 600: - return ServiceError('%d %s %s' % (status, code, message)) + return ServiceError( + '%d %s %s' % (status, code, message), + retry_after_seconds=response_headers.get('retry-after'), + ) return UnknownError('%d %s %s' % (status, code, message)) diff --git a/changelog.d/606.fixed.md b/changelog.d/606.fixed.md new file mode 100644 index 000000000..bd710ff39 --- /dev/null +++ b/changelog.d/606.fixed.md @@ -0,0 +1 @@ +Fixed honoring the Retry-After header on 5xx service errors. diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py index 258e301a4..f4f6e8c96 100644 --- a/test/unit/test_exception.py +++ b/test/unit/test_exception.py @@ -147,6 +147,15 @@ def test_too_many_requests_without_retry_after_header(self): error = self._check_one(TooManyRequests, 429, '', '', {}) assert error.retry_after_seconds is None + def test_service_error_with_retry_after_header(self): + retry_after = 200 + error = self._check_one(ServiceError, 503, '', '', {'retry-after': retry_after}) + assert error.retry_after_seconds == retry_after + + def test_service_error_without_retry_after_header(self): + error = self._check_one(ServiceError, 503, '', '', {}) + assert error.retry_after_seconds is None + @pytest.mark.apiver( from_ver=3 ) # previous apivers throw this as well, but BucketIdNotFound is a different class in them From 39f08e3996744b6ebb8f7c4e9c55546873337f28 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Thu, 1 Oct 2026 11:00:48 -0600 Subject: [PATCH 2/2] fix: safely handle non-numeric Retry-After --- b2sdk/_internal/exception.py | 18 ++++++++++-------- test/unit/b2http/test_b2http.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index d692a778c..8851a99d2 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -27,6 +27,14 @@ logger = logging.getLogger(__name__) +def _parse_retry_after_seconds(retry_after_seconds): + """Return Retry-After delay-seconds, ignoring HTTP-date and invalid forms.""" + try: + return int(retry_after_seconds) + except (TypeError, ValueError): + return None + + class B2Error(Exception, metaclass=ABCMeta): def __init__(self, *args, **kwargs): """ @@ -435,9 +443,7 @@ class ServiceError(TransientErrorMixin, B2Error): def __init__(self, *args, retry_after_seconds=None): super().__init__(*args) - - if retry_after_seconds is not None: - self.retry_after_seconds = int(retry_after_seconds) + self.retry_after_seconds = _parse_retry_after_seconds(retry_after_seconds) class CapExceeded(B2Error): @@ -458,11 +464,7 @@ def __str__(self): class TooManyRequests(B2Error): def __init__(self, retry_after_seconds=None): super().__init__() - - if retry_after_seconds is not None: - self.retry_after_seconds = int(retry_after_seconds) - else: - self.retry_after_seconds = None + self.retry_after_seconds = _parse_retry_after_seconds(retry_after_seconds) def __str__(self): return 'Too many requests' diff --git a/test/unit/b2http/test_b2http.py b/test/unit/b2http/test_b2http.py index ae91e6bfa..6ae18269e 100644 --- a/test/unit/b2http/test_b2http.py +++ b/test/unit/b2http/test_b2http.py @@ -332,6 +332,30 @@ def test_works_second_try_status(self, b2_http: B2Http, mock_time: MagicMock): b2_http.request(responses.GET, self.URL, {}) mock_time.assert_called_once_with(1.0) + @responses.activate + def test_service_error_honors_retry_after(self, b2_http: B2Http, mock_time: MagicMock): + _mock_error_response(self.URL, status=503, headers={'Retry-After': '2'}) + responses.get(self.URL) + + b2_http.request(responses.GET, self.URL, {}) + + mock_time.assert_called_once_with(2) + + @responses.activate + def test_service_error_ignores_http_date_retry_after( + self, b2_http: B2Http, mock_time: MagicMock + ): + _mock_error_response( + self.URL, + status=503, + headers={'Retry-After': 'Wed, 21 Oct 2015 07:28:00 GMT'}, + ) + responses.get(self.URL) + + b2_http.request(responses.GET, self.URL, {}) + + mock_time.assert_called_once_with(1.0) + @responses.activate def test_never_works(self, b2_http: B2Http, mock_time: MagicMock): _mock_error_response(self.URL, status=503)