diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index aef410f3..8851a99d 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): """ @@ -433,6 +441,10 @@ class ServiceError(TransientErrorMixin, B2Error): Used for HTTP status codes 500 through 599. """ + def __init__(self, *args, retry_after_seconds=None): + super().__init__(*args) + self.retry_after_seconds = _parse_retry_after_seconds(retry_after_seconds) + class CapExceeded(B2Error): def __str__(self): @@ -452,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' @@ -753,5 +761,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 00000000..bd710ff3 --- /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/b2http/test_b2http.py b/test/unit/b2http/test_b2http.py index ae91e6bf..6ae18269 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) diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py index 258e301a..f4f6e8c9 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