Skip to content

Commit f18e7d0

Browse files
committed
[NA] [SDK] fix: retry transient S3 attachment failures
1 parent 867d65e commit f18e7d0

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

sdks/python/src/opik/s3_httpx_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
WRITE_TIMEOUT_SECONDS = 100
1212
POOL_TIMEOUT_SECONDS = 20
1313

14-
RETRYABLE_STATUS_CODES = [500]
14+
RETRYABLE_STATUS_CODES = [500, 502, 503, 504]
1515

1616

1717
@functools.lru_cache
@@ -47,6 +47,7 @@ def _allowed_to_retry(exception: Exception) -> bool:
4747
if isinstance(
4848
exception,
4949
(
50+
httpx.RemoteProtocolError, # handle retries for expired connections
5051
httpx.ConnectError,
5152
httpx.TimeoutException,
5253
),
@@ -62,6 +63,7 @@ def _allowed_to_retry(exception: Exception) -> bool:
6263

6364
s3_retry = tenacity.retry(
6465
stop=tenacity.stop_after_attempt(3),
65-
wait=tenacity.wait_exponential(multiplier=5, min=10, max=45),
66+
wait=tenacity.wait_random_exponential(multiplier=1, min=1, max=10),
6667
retry=tenacity.retry_if_exception(_allowed_to_retry),
68+
reraise=True,
6769
)

sdks/python/tests/unit/file_upload/s3_multipart_upload/test_s3_file_uploader.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,18 @@ def test_upload_file_parts_to_s3__error_status(data_file, respx_mock):
8585
with pytest.raises(s3_upload_error.S3UploadFileError):
8686
uploader.upload()
8787

88+
route = respx.put(rx_url)
89+
assert route.call_count == 1
90+
8891

8992
class TestS3FileDataUploaderRetry:
9093
# It is done as it is to patch retry decorator to minimize a retry interval
9194
def setup_method(self):
9295
s3_retry = tenacity.retry(
9396
stop=tenacity.stop_after_attempt(3),
94-
wait=tenacity.wait_exponential(multiplier=1, min=0.5, max=2),
97+
wait=tenacity.wait_none(),
9598
retry=tenacity.retry_if_exception(s3_httpx_client._allowed_to_retry),
99+
reraise=True,
96100
)
97101
# Now patch the decorator where the decorator is being imported from
98102
patch(
@@ -108,7 +112,10 @@ def teardown_method(self):
108112
# Reload our module, which restores the original decorator
109113
importlib.reload(s3_file_uploader)
110114

111-
def test_upload_file_parts_to_s3__retry_on_500(self, data_file, respx_mock):
115+
@pytest.mark.parametrize("status_code", [500, 502, 503, 504])
116+
def test_upload_file_parts_to_s3__retryable_status__retries(
117+
self, data_file, respx_mock, status_code
118+
):
112119
max_file_part_size = 5 * 1024 * 1024
113120
file_parts = file_parts_strategy.FilePartsStrategy(
114121
file_path=data_file.name,
@@ -124,7 +131,7 @@ def test_upload_file_parts_to_s3__retry_on_500(self, data_file, respx_mock):
124131

125132
def retry_side_effect(request, route):
126133
if route.call_count < 1:
127-
return httpx.Response(500)
134+
return httpx.Response(status_code)
128135
else:
129136
return httpx.Response(200, headers={"ETag": "e-tag"})
130137

@@ -146,4 +153,47 @@ def retry_side_effect(request, route):
146153
assert monitor.bytes_sent == conftest.FILE_SIZE
147154

148155
route = respx.put(rx_url)
149-
assert route.call_count == 3 + 1 # we have one retry due to 500
156+
assert route.call_count == 3 + 1
157+
158+
def test_upload_file_parts_to_s3__remote_protocol_error__retries(
159+
self, data_file, respx_mock
160+
):
161+
max_file_part_size = 5 * 1024 * 1024
162+
file_parts = file_parts_strategy.FilePartsStrategy(
163+
file_path=data_file.name,
164+
file_size=conftest.FILE_SIZE,
165+
max_file_part_size=max_file_part_size,
166+
)
167+
pre_sign_urls = [
168+
"https://s3.amazonaws.com/bucket/1",
169+
"https://s3.amazonaws.com/bucket/2",
170+
"https://s3.amazonaws.com/bucket/3",
171+
]
172+
rx_url = re.compile("https://s3\\.amazonaws\\.com/bucket/*")
173+
174+
def retry_side_effect(request, route):
175+
if route.call_count < 1:
176+
raise httpx.RemoteProtocolError(
177+
"Server disconnected without sending a response",
178+
request=request,
179+
)
180+
return httpx.Response(200, headers={"ETag": "e-tag"})
181+
182+
respx_mock.put(rx_url).mock(side_effect=retry_side_effect)
183+
184+
httpx_client = s3_httpx_client.get()
185+
monitor = file_upload_monitor.FileUploadMonitor()
186+
187+
uploader = s3_file_uploader.S3FileDataUploader(
188+
file_parts=file_parts,
189+
pre_sign_urls=pre_sign_urls,
190+
httpx_client=httpx_client,
191+
monitor=monitor,
192+
)
193+
194+
uploader.upload()
195+
196+
assert monitor.bytes_sent == conftest.FILE_SIZE
197+
198+
route = respx.put(rx_url)
199+
assert route.call_count == 3 + 1

sdks/python/tests/unit/test_s3_httpx_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from unittest import mock
22

3+
import httpx
34
import opik.hooks
5+
import pytest
6+
47
from opik import s3_httpx_client
58
from opik.s3_httpx_client import (
69
CONNECT_TIMEOUT_SECONDS,
@@ -68,3 +71,38 @@ def test_httpx_client_hooks__callable_hook_applied__with_arguments_hook_applied_
6871
def test_get_httpx_client__no_hooks():
6972
client = s3_httpx_client.get()
7073
assert client is not None
74+
75+
76+
def test_allowed_to_retry__remote_protocol_error__returns_true():
77+
request = httpx.Request("PUT", "https://s3.amazonaws.com/bucket/1")
78+
error = httpx.RemoteProtocolError(
79+
"Server disconnected without sending a response",
80+
request=request,
81+
)
82+
83+
assert s3_httpx_client._allowed_to_retry(error) is True
84+
85+
86+
@pytest.mark.parametrize("status_code", [500, 502, 503, 504])
87+
def test_allowed_to_retry__transient_status__returns_true(status_code):
88+
request = httpx.Request("PUT", "https://s3.amazonaws.com/bucket/1")
89+
response = httpx.Response(status_code, request=request)
90+
error = httpx.HTTPStatusError(
91+
"Transient S3 error",
92+
request=request,
93+
response=response,
94+
)
95+
96+
assert s3_httpx_client._allowed_to_retry(error) is True
97+
98+
99+
def test_allowed_to_retry__non_transient_status__returns_false():
100+
request = httpx.Request("PUT", "https://s3.amazonaws.com/bucket/1")
101+
response = httpx.Response(403, request=request)
102+
error = httpx.HTTPStatusError(
103+
"Non-transient S3 error",
104+
request=request,
105+
response=response,
106+
)
107+
108+
assert s3_httpx_client._allowed_to_retry(error) is False

0 commit comments

Comments
 (0)