diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index aef410f3..7311ced2 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -434,6 +434,14 @@ class ServiceError(TransientErrorMixin, B2Error): """ +class RequestTimeout(TransientErrorMixin, B2Error): + """A server HTTP 408 response, raised by :func:`interpret_b2_error` for status 408. + + Unlike :class:`B2RequestTimeout`, this is not a client-side socket or connection timeout + raised in ``b2http.py``. + """ + + class CapExceeded(B2Error): def __str__(self): return 'Cap exceeded.' @@ -750,6 +758,8 @@ def interpret_b2_error( return Conflict() elif status == 416 and code == 'range_not_satisfiable': return UnsatisfiableRange() + elif status == 408: + return RequestTimeout('%d %s %s' % (status, code, message)) elif status == 429: return TooManyRequests(retry_after_seconds=response_headers.get('retry-after')) elif 500 <= status < 600: diff --git a/b2sdk/v3/exception.py b/b2sdk/v3/exception.py index 36143d56..7918e314 100644 --- a/b2sdk/v3/exception.py +++ b/b2sdk/v3/exception.py @@ -63,6 +63,7 @@ NotAllowedByAppKeyError, PartSha1Mismatch, PotentialS3EndpointPassedAsRealm, + RequestTimeout, RestrictedBucket, RestrictedBucketMissing, RetentionWriteError, @@ -154,6 +155,7 @@ 'NotAllowedByAppKeyError', 'PartSha1Mismatch', 'PotentialS3EndpointPassedAsRealm', + 'RequestTimeout', 'RestrictedBucket', 'RestrictedBucketMissing', 'RetentionWriteError', diff --git a/changelog.d/605.fixed.md b/changelog.d/605.fixed.md new file mode 100644 index 00000000..47d11f87 --- /dev/null +++ b/changelog.d/605.fixed.md @@ -0,0 +1 @@ +Retry uploads after a B2 request-timeout response (HTTP 408). diff --git a/test/unit/bucket/test_bucket.py b/test/unit/bucket/test_bucket.py index ead9a449..f140a661 100644 --- a/test/unit/bucket/test_bucket.py +++ b/test/unit/bucket/test_bucket.py @@ -43,6 +43,7 @@ InvalidRange, InvalidUploadSource, MaxRetriesExceeded, + RequestTimeout, RestrictedBucketMissing, SourceReplicationConflict, SSECKeyError, @@ -2102,6 +2103,14 @@ def test_upload_timeout(self): data = b'hello world' self.bucket.upload_bytes(data, 'file1') + def test_upload_http_request_timeout(self): + self.simulator.set_upload_errors([RequestTimeout('408 request_timeout request timeout')]) + data = b'hello world' + + self.bucket.upload_bytes(data, 'file1') + + self._check_file_contents('file1', data) + def test_upload_file_one_fatal_error(self): self.simulator.set_upload_errors([CanRetry(False)]) data = b'hello world' diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py index 258e301a..19e2ce81 100644 --- a/test/unit/test_exception.py +++ b/test/unit/test_exception.py @@ -35,7 +35,7 @@ interpret_b2_error, ) -from b2sdk._internal.exception import ResourceNotFound +from b2sdk._internal.exception import RequestTimeout, ResourceNotFound class TestB2Error: @@ -161,6 +161,12 @@ def test_service_error(self): assert isinstance(error, ServiceError) assert '500 code message' == str(error) + def test_request_timeout(self): + error = interpret_b2_error(408, 'request_timeout', 'request timeout', {}) + assert isinstance(error, RequestTimeout) + assert error.should_retry_upload() + assert error.should_retry_http() + def test_unknown_error(self): error = interpret_b2_error(499, 'code', 'message', {}) assert isinstance(error, UnknownError)