From deca687c539112694374e1347dcd91b2fc852fa5 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Mon, 28 Sep 2026 11:27:55 -0600 Subject: [PATCH 1/2] fix: retry request timeout upload responses --- b2sdk/_internal/exception.py | 6 ++++++ changelog.d/605.fixed.md | 1 + test/unit/test_exception.py | 8 +++++++- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelog.d/605.fixed.md diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index aef410f37..15bf2d899 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -434,6 +434,10 @@ class ServiceError(TransientErrorMixin, B2Error): """ +class RequestTimeout(TransientErrorMixin, B2Error): + """Used for HTTP status code 408 (request timeout).""" + + class CapExceeded(B2Error): def __str__(self): return 'Cap exceeded.' @@ -750,6 +754,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/changelog.d/605.fixed.md b/changelog.d/605.fixed.md new file mode 100644 index 000000000..47d11f87e --- /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/test_exception.py b/test/unit/test_exception.py index 258e301a4..19e2ce819 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) From 4e0346a3c2b359287eaddbf21fca1b1a369365a7 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Thu, 1 Oct 2026 11:02:47 -0600 Subject: [PATCH 2/2] fix: expose HTTP request timeout exception --- b2sdk/_internal/exception.py | 6 +++++- b2sdk/v3/exception.py | 2 ++ test/unit/bucket/test_bucket.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/b2sdk/_internal/exception.py b/b2sdk/_internal/exception.py index 15bf2d899..7311ced28 100644 --- a/b2sdk/_internal/exception.py +++ b/b2sdk/_internal/exception.py @@ -435,7 +435,11 @@ class ServiceError(TransientErrorMixin, B2Error): class RequestTimeout(TransientErrorMixin, B2Error): - """Used for HTTP status code 408 (request timeout).""" + """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): diff --git a/b2sdk/v3/exception.py b/b2sdk/v3/exception.py index 36143d567..7918e3142 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/test/unit/bucket/test_bucket.py b/test/unit/bucket/test_bucket.py index ead9a449a..f140a6612 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'