Skip to content

Commit d5f1d2d

Browse files
committed
[NA] [SDK] fix: retain disconnected S3 uploads for replay
1 parent f18e7d0 commit d5f1d2d

4 files changed

Lines changed: 92 additions & 28 deletions

File tree

sdks/python/src/opik/file_upload/s3_multipart_upload/s3_file_uploader.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ def upload(self) -> List[PartMetadata]:
4545
with file_to_upload.open("rb") as fp:
4646
self._upload(fp=fp)
4747
except Exception as e:
48-
connection_error = isinstance(e, httpx.ConnectError) or isinstance(
49-
e, httpx.TimeoutException
48+
connection_error = isinstance(
49+
e,
50+
s3_httpx_client.RETRYABLE_CONNECTION_ERRORS,
5051
)
5152
raise s3_upload_error.S3UploadFileError(
5253
file=self._file_parts.file,

sdks/python/src/opik/s3_httpx_client.py

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

14-
RETRYABLE_STATUS_CODES = [500, 502, 503, 504]
14+
RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
15+
RETRYABLE_CONNECTION_ERRORS = (
16+
httpx.RemoteProtocolError, # handle retries for expired connections
17+
httpx.ConnectError,
18+
httpx.TimeoutException,
19+
)
1520

1621

1722
@functools.lru_cache
@@ -44,14 +49,7 @@ def get() -> httpx.Client:
4449

4550

4651
def _allowed_to_retry(exception: Exception) -> bool:
47-
if isinstance(
48-
exception,
49-
(
50-
httpx.RemoteProtocolError, # handle retries for expired connections
51-
httpx.ConnectError,
52-
httpx.TimeoutException,
53-
),
54-
):
52+
if isinstance(exception, RETRYABLE_CONNECTION_ERRORS):
5553
return True
5654

5755
if isinstance(exception, httpx.HTTPStatusError):

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

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import re
2-
import importlib
32
from unittest.mock import patch
43

54
import httpx
@@ -90,27 +89,16 @@ def test_upload_file_parts_to_s3__error_status(data_file, respx_mock):
9089

9190

9291
class TestS3FileDataUploaderRetry:
93-
# It is done as it is to patch retry decorator to minimize a retry interval
92+
# Patch only the wait strategy so tests exercise the production retry policy.
9493
def setup_method(self):
95-
s3_retry = tenacity.retry(
96-
stop=tenacity.stop_after_attempt(3),
97-
wait=tenacity.wait_none(),
98-
retry=tenacity.retry_if_exception(s3_httpx_client._allowed_to_retry),
99-
reraise=True,
100-
)
101-
# Now patch the decorator where the decorator is being imported from
102-
patch(
103-
"opik.s3_httpx_client.s3_retry",
104-
lambda x: s3_retry(x),
94+
patch.object(
95+
s3_file_uploader.S3FileDataUploader._send_data_part.retry,
96+
"wait",
97+
tenacity.wait_none(),
10598
).start()
106-
# Reloads the module which applies our patched decorator
107-
importlib.reload(s3_file_uploader)
10899

109100
def teardown_method(self):
110-
# Stops all patches started with start()
111101
patch.stopall()
112-
# Reload our module, which restores the original decorator
113-
importlib.reload(s3_file_uploader)
114102

115103
@pytest.mark.parametrize("status_code", [500, 502, 503, 504])
116104
def test_upload_file_parts_to_s3__retryable_status__retries(
@@ -128,8 +116,10 @@ def test_upload_file_parts_to_s3__retryable_status__retries(
128116
"https://s3.amazonaws.com/bucket/3",
129117
]
130118
rx_url = re.compile("https://s3\\.amazonaws\\.com/bucket/*")
119+
requests: list[tuple[httpx.URL, bytes]] = []
131120

132121
def retry_side_effect(request, route):
122+
requests.append((request.url, request.content))
133123
if route.call_count < 1:
134124
return httpx.Response(status_code)
135125
else:
@@ -154,6 +144,7 @@ def retry_side_effect(request, route):
154144

155145
route = respx.put(rx_url)
156146
assert route.call_count == 3 + 1
147+
assert requests[0] == requests[1]
157148

158149
def test_upload_file_parts_to_s3__remote_protocol_error__retries(
159150
self, data_file, respx_mock
@@ -170,8 +161,10 @@ def test_upload_file_parts_to_s3__remote_protocol_error__retries(
170161
"https://s3.amazonaws.com/bucket/3",
171162
]
172163
rx_url = re.compile("https://s3\\.amazonaws\\.com/bucket/*")
164+
requests: list[tuple[httpx.URL, bytes]] = []
173165

174166
def retry_side_effect(request, route):
167+
requests.append((request.url, request.content))
175168
if route.call_count < 1:
176169
raise httpx.RemoteProtocolError(
177170
"Server disconnected without sending a response",
@@ -197,3 +190,45 @@ def retry_side_effect(request, route):
197190

198191
route = respx.put(rx_url)
199192
assert route.call_count == 3 + 1
193+
assert requests[0] == requests[1]
194+
195+
def test_upload_file_parts_to_s3__remote_protocol_error_exhausted__retains_for_replay(
196+
self, data_file, respx_mock
197+
):
198+
file_parts = file_parts_strategy.FilePartsStrategy(
199+
file_path=data_file.name,
200+
file_size=conftest.FILE_SIZE,
201+
)
202+
pre_sign_urls = [
203+
"https://s3.amazonaws.com/bucket/1",
204+
"https://s3.amazonaws.com/bucket/2",
205+
"https://s3.amazonaws.com/bucket/3",
206+
]
207+
rx_url = re.compile("https://s3\\.amazonaws\\.com/bucket/*")
208+
209+
def remote_protocol_error(request, route):
210+
raise httpx.RemoteProtocolError(
211+
"Server disconnected without sending a response",
212+
request=request,
213+
)
214+
215+
respx_mock.put(rx_url).mock(side_effect=remote_protocol_error)
216+
217+
uploader = s3_file_uploader.S3FileDataUploader(
218+
file_parts=file_parts,
219+
pre_sign_urls=pre_sign_urls,
220+
httpx_client=s3_httpx_client.get(),
221+
)
222+
223+
with pytest.raises(s3_upload_error.S3UploadFileError) as exc_info:
224+
uploader.upload()
225+
226+
upload_error = exc_info.value
227+
assert upload_error.connection_error is True
228+
assert isinstance(upload_error.__cause__, httpx.RemoteProtocolError)
229+
assert str(upload_error.__cause__) == (
230+
"Server disconnected without sending a response"
231+
)
232+
233+
route = respx.put(rx_url)
234+
assert route.call_count == 3

sdks/python/tests/unit/message_processing/processors/test_opik_message_processor_replay.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,36 @@ def fake_upload(message, on_upload_success=None, on_upload_failed=None):
760760
72, failure_reason=str(error)
761761
)
762762

763+
def test_process__attachment__failed_callback_s3_remote_disconnect__retains_for_replay(
764+
self,
765+
processor: OpikMessageProcessor,
766+
mock_replay: mock.MagicMock,
767+
mock_file_uploader: mock.MagicMock,
768+
):
769+
msg = _create_attachment_message(message_id=74)
770+
remote_error = httpx.RemoteProtocolError(
771+
"Server disconnected without sending a response"
772+
)
773+
error = s3_upload_error.S3UploadFileError(
774+
file="attachment.bin",
775+
reason=str(remote_error),
776+
connection_error=True,
777+
)
778+
error.__cause__ = remote_error
779+
780+
def fake_upload(message, on_upload_success=None, on_upload_failed=None):
781+
on_upload_failed(error)
782+
783+
mock_file_uploader.upload.side_effect = fake_upload
784+
mock_replay.reset_mock()
785+
786+
processor.process(msg)
787+
788+
mock_replay.message_sent_failed.assert_called_once_with(
789+
74, failure_reason=str(error)
790+
)
791+
mock_replay.unregister_message.assert_not_called()
792+
763793
def test_process__attachment__failed_callback_s3_non_connection_error__no_failed_mark(
764794
self,
765795
processor: OpikMessageProcessor,

0 commit comments

Comments
 (0)