Skip to content

Commit 6d8c8f1

Browse files
committed
Prove Python TUS retry state transitions
1 parent 7337b68 commit 6d8c8f1

5 files changed

Lines changed: 303 additions & 22 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Prove TUS retry attempt state resets after recovered progress."""
2+
3+
import sys
4+
from io import BytesIO
5+
from pathlib import Path
6+
7+
from tusclient import client as tus
8+
9+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10+
from api2devdock import (
11+
TusConformancePlanServer,
12+
conformance_input_options,
13+
conformance_input_source_bytes,
14+
conformance_retry_decisions,
15+
fail,
16+
int_array_value,
17+
load_scenario,
18+
object_value,
19+
scenario_id,
20+
string_value,
21+
write_result,
22+
)
23+
24+
25+
class RetryStateObserver:
26+
def __init__(self, retry_decisions, retry_delays):
27+
self.retry_decisions = retry_decisions
28+
self.retry_delays = retry_delays
29+
self.events = []
30+
self.index = 0
31+
32+
def on_should_retry(self, error, retry_attempt):
33+
if self.index >= len(self.retry_decisions):
34+
fail(
35+
"retry state scenario observed unexpected retry attempt {}".format(
36+
retry_attempt,
37+
)
38+
)
39+
40+
expected = self.retry_decisions[self.index]
41+
if retry_attempt != expected["retryAttempt"]:
42+
fail(
43+
"retry state scenario expected retry attempt {}, got {}".format(
44+
expected["retryAttempt"],
45+
retry_attempt,
46+
)
47+
)
48+
49+
decision = expected["decision"]
50+
self.events.append(
51+
{
52+
"decision": decision,
53+
"kind": "should-retry",
54+
"retryAttempt": retry_attempt,
55+
}
56+
)
57+
58+
if decision:
59+
if retry_attempt >= len(self.retry_delays):
60+
fail(
61+
"retry state scenario has no delay for retry attempt {}".format(
62+
retry_attempt,
63+
)
64+
)
65+
self.events.append(
66+
{
67+
"delay": self.retry_delays[retry_attempt],
68+
"kind": "retry-schedule",
69+
}
70+
)
71+
72+
self.index += 1
73+
return decision
74+
75+
def assert_complete(self):
76+
if self.index == len(self.retry_decisions):
77+
return
78+
79+
fail(
80+
"retry state scenario observed {} retry decision(s), expected {}".format(
81+
self.index,
82+
len(self.retry_decisions),
83+
)
84+
)
85+
86+
87+
def upload_with_retry_state_transitions(conformance_scenario):
88+
input_options = conformance_input_options(conformance_scenario)
89+
content = conformance_input_source_bytes(conformance_scenario)
90+
endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl")
91+
metadata = object_value(input_options["metadata"], "metadata")
92+
retry_delays = int_array_value(input_options["retryDelays"], "retryDelays")
93+
retry_decisions = conformance_retry_decisions(conformance_scenario)
94+
completion = object_value(
95+
conformance_scenario["completion"],
96+
"conformanceScenario.completion",
97+
)
98+
completion_kind = string_value(
99+
completion["kind"],
100+
"conformanceScenario.completion.kind",
101+
)
102+
observer = RetryStateObserver(retry_decisions, retry_delays)
103+
104+
with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server:
105+
client = tus.TusClient(conformance_server.endpoint_url())
106+
uploader = client.uploader(
107+
file_stream=BytesIO(content),
108+
metadata=metadata,
109+
on_should_retry=observer.on_should_retry,
110+
retry_delays=retry_delays,
111+
)
112+
uploader.upload()
113+
observer.assert_complete()
114+
115+
if not uploader.url:
116+
fail("retry state scenario did not expose an upload URL")
117+
if uploader.offset != len(content):
118+
fail(
119+
"retry state scenario upload offset {}, expected {}".format(
120+
uploader.offset,
121+
len(content),
122+
)
123+
)
124+
125+
conformance_server.assert_exhausted()
126+
result = conformance_server.result()
127+
result["completionKind"] = completion_kind
128+
result["errorCalled"] = False
129+
result["eventCount"] = len(observer.events)
130+
result["events"] = observer.events
131+
result["successCalled"] = True
132+
result["uploadUrl"] = conformance_server.canonical_url(uploader.url)
133+
return result
134+
135+
136+
def main():
137+
scenario = load_scenario(Path(__file__).with_name("api2-scenario.json"))
138+
conformance_scenario = object_value(
139+
scenario["conformanceScenario"],
140+
"conformanceScenario",
141+
)
142+
result = upload_with_retry_state_transitions(conformance_scenario)
143+
write_result(result)
144+
print(
145+
"Python TUS SDK devdock scenario {} proved retry state transitions".format(
146+
scenario_id(scenario),
147+
)
148+
)
149+
150+
151+
if __name__ == "__main__":
152+
main()

examples/api2devdock.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,33 @@ def conformance_input_source_kind(conformance_scenario):
139139
return string_value(input_source["kind"], "conformanceScenario.inputSource.kind")
140140

141141

142+
def conformance_retry_decisions(conformance_scenario):
143+
decisions = conformance_scenario["retryDecisions"]
144+
if not isinstance(decisions, list):
145+
fail("conformanceScenario.retryDecisions must be a list")
146+
147+
result = []
148+
for index, decision in enumerate(decisions):
149+
decision = object_value(
150+
decision,
151+
"conformanceScenario.retryDecisions[{}]".format(index),
152+
)
153+
result.append(
154+
{
155+
"decision": bool_value(
156+
decision["decision"],
157+
"conformanceScenario.retryDecisions[{}].decision".format(index),
158+
),
159+
"retryAttempt": int_value(
160+
decision["retryAttempt"],
161+
"conformanceScenario.retryDecisions[{}].retryAttempt".format(index),
162+
),
163+
}
164+
)
165+
166+
return result
167+
168+
142169
def conformance_scenario_wants_event(conformance_scenario, event_kind):
143170
events = conformance_scenario.get("events", [])
144171
if not isinstance(events, list):

tests/test_uploader.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,74 @@ def test_upload_retry(self, request_mock):
297297
self.uploader.upload_chunk()
298298
self.assertEqual(self.uploader._retried, num_of_retries)
299299

300+
@mock.patch('tusclient.uploader.uploader.TusRequest')
301+
def test_upload_retry_delays_and_should_retry_reset_after_progress(self, request_mock):
302+
first_failure = mock.Mock()
303+
first_failure.status_code = 500
304+
first_failure.response_content = b''
305+
first_failure.response_headers = {}
306+
first_failure.perform.return_value = None
307+
308+
second_failure = mock.Mock()
309+
second_failure.status_code = 500
310+
second_failure.response_content = b''
311+
second_failure.response_headers = {}
312+
second_failure.perform.return_value = None
313+
314+
success = mock.Mock()
315+
success.status_code = 204
316+
success.response_content = b''
317+
success.response_headers = {'upload-offset': '11'}
318+
success.perform.return_value = None
319+
320+
retry_attempts = []
321+
322+
def should_retry(error, retry_attempt):
323+
retry_attempts.append(retry_attempt)
324+
return True
325+
326+
self.uploader.retries = 0
327+
self.uploader.retry_delays = [250]
328+
self.uploader.on_should_retry = should_retry
329+
self.uploader.offset = 0
330+
request_mock.side_effect = [first_failure, second_failure, success]
331+
332+
with mock.patch.object(self.uploader, 'get_offset', side_effect=[5, 5]) as get_offset:
333+
with mock.patch('tusclient.uploader.uploader.time.sleep') as sleep:
334+
self.uploader.upload_chunk()
335+
336+
self.assertEqual(retry_attempts, [0, 0])
337+
self.assertEqual(self.uploader.offset, 11)
338+
self.assertEqual(get_offset.call_count, 2)
339+
sleep.assert_has_calls([mock.call(0.25), mock.call(0.25)])
340+
341+
@mock.patch('tusclient.uploader.uploader.TusRequest')
342+
def test_upload_retry_stops_when_should_retry_returns_false(self, request_mock):
343+
failure = mock.Mock()
344+
failure.status_code = 500
345+
failure.response_content = b''
346+
failure.response_headers = {}
347+
failure.perform.return_value = None
348+
349+
retry_attempts = []
350+
351+
def should_retry(error, retry_attempt):
352+
retry_attempts.append(retry_attempt)
353+
return False
354+
355+
self.uploader.retries = 0
356+
self.uploader.retry_delays = [0]
357+
self.uploader.on_should_retry = should_retry
358+
request_mock.side_effect = [failure]
359+
360+
with mock.patch.object(self.uploader, 'get_offset') as get_offset:
361+
with pytest.raises(exceptions.TusCommunicationError):
362+
self.uploader.upload_chunk()
363+
364+
self.assertEqual(retry_attempts, [0])
365+
self.assertEqual(self.uploader._retried, 0)
366+
get_offset.assert_not_called()
367+
300368
@responses.activate
301369
def test_upload_empty(self):
302370
responses.add(

tusclient/uploader/baseuploader.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class BaseUploader:
8787
Callback invoked with bytes sent and total bytes after upload progress changes.
8888
- on_chunk_complete (Optional[Callable]):
8989
Callback invoked with chunk size, accepted offset, and total bytes after a chunk is accepted.
90+
- on_should_retry (Optional[Callable]):
91+
Callback invoked with an error and retry attempt before scheduling a retry.
9092
9193
:Constructor Args:
9294
- file_path (str)
@@ -139,6 +141,7 @@ def __init__(
139141
parallel_upload_boundaries=None,
140142
protocol: Optional[str] = None,
141143
retry_delays=None,
144+
on_should_retry: Optional[Callable[[Exception, int], bool]] = None,
142145
on_progress: Optional[Callable[[int, Optional[int]], None]] = None,
143146
on_chunk_complete: Optional[Callable[[int, int, Optional[int]], None]] = None,
144147
):
@@ -196,6 +199,7 @@ def __init__(
196199
self.parallel_upload_boundaries = parallel_upload_boundaries
197200
self.override_patch_method = override_patch_method
198201
self.retry_delays = retry_delays
202+
self.on_should_retry = on_should_retry
199203
self.on_progress = on_progress
200204
self.on_chunk_complete = on_chunk_complete
201205
self._abort_requested = Event()
@@ -232,6 +236,24 @@ def request_method_input_options(self):
232236
"override_patch_method": self.override_patch_method,
233237
}
234238

239+
def _retry_limit(self):
240+
if self.retry_delays is not None:
241+
return len(self.retry_delays)
242+
243+
return self.retries
244+
245+
def _retry_delay_seconds(self, retry_attempt):
246+
if self.retry_delays is not None:
247+
return self.retry_delays[retry_attempt] / 1000
248+
249+
return self.retry_delay
250+
251+
def _should_retry(self, error, retry_attempt):
252+
if self.on_should_retry is None:
253+
return True
254+
255+
return bool(self.on_should_retry(error, retry_attempt))
256+
235257
def run_before_request(self, method, url, headers):
236258
if self.client is not None:
237259
self.client._set_current_uploader(self)

tusclient/uploader/uploader.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -208,19 +208,25 @@ def _do_request(self):
208208
self._retry_or_cry(error)
209209

210210
def _retry_or_cry(self, error):
211-
if self.retries > self._retried:
212-
time.sleep(self.retry_delay)
213-
214-
self._retried += 1
215-
try:
216-
self.offset = self.get_offset()
217-
except TusCommunicationError as err:
218-
self._retry_or_cry(err)
219-
else:
220-
self._do_request()
221-
else:
211+
retry_attempt = self._retried
212+
if self._retry_limit() <= retry_attempt:
213+
raise error
214+
if not self._should_retry(error, retry_attempt):
222215
raise error
223216

217+
time.sleep(self._retry_delay_seconds(retry_attempt))
218+
self._retried += 1
219+
previous_offset = self.offset
220+
try:
221+
recovered_offset = self.get_offset()
222+
except TusCommunicationError as err:
223+
self._retry_or_cry(err)
224+
else:
225+
if recovered_offset > previous_offset:
226+
self._retried = 0
227+
self.offset = recovered_offset
228+
self._do_request()
229+
224230

225231
class AsyncUploader(BaseUploader):
226232
def __init__(self, *args, **kwargs):
@@ -323,15 +329,21 @@ async def _do_request(self):
323329
await self._retry_or_cry(error)
324330

325331
async def _retry_or_cry(self, error):
326-
if self.retries > self._retried:
327-
await asyncio.sleep(self.retry_delay)
328-
329-
self._retried += 1
330-
try:
331-
self.offset = self.get_offset()
332-
except TusCommunicationError as err:
333-
await self._retry_or_cry(err)
334-
else:
335-
await self._do_request()
336-
else:
332+
retry_attempt = self._retried
333+
if self._retry_limit() <= retry_attempt:
334+
raise error
335+
if not self._should_retry(error, retry_attempt):
337336
raise error
337+
338+
await asyncio.sleep(self._retry_delay_seconds(retry_attempt))
339+
self._retried += 1
340+
previous_offset = self.offset
341+
try:
342+
recovered_offset = self.get_offset()
343+
except TusCommunicationError as err:
344+
await self._retry_or_cry(err)
345+
else:
346+
if recovered_offset > previous_offset:
347+
self._retried = 0
348+
self.offset = recovered_offset
349+
await self._do_request()

0 commit comments

Comments
 (0)