Skip to content

Commit 83ea871

Browse files
committed
Polish async retries and uploads
1 parent f8d3482 commit 83ea871

3 files changed

Lines changed: 61 additions & 24 deletions

File tree

tests/test_async_client.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ async def test_async_client_delete_template_get_bill_and_plain_text_fallback(sel
392392
self.assertEqual(response.status_code, 200)
393393
self.assertEqual(response.headers["X-Async-Route"], "get_assembly_plain")
394394

395-
async def test_async_assembly_create_returns_plain_text_response_without_crashing(self):
395+
async def test_async_assembly_create_raises_on_plain_text_error_response(self):
396396
plain_response = Response(
397397
data="plain assembly response",
398398
status_code=502,
@@ -403,17 +403,12 @@ async def test_async_assembly_create_returns_plain_text_response_without_crashin
403403
assembly = client.new_assembly()
404404

405405
with mock.patch.object(client.request, "post", new=mock.AsyncMock(return_value=plain_response)) as post_mock:
406-
with mock.patch.object(client, "get_assembly", new=mock.AsyncMock()) as get_mock:
407-
with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock) as sleep_mock:
408-
response = await assembly.create(wait=True, resumable=False)
406+
with self.assertRaises(RuntimeError):
407+
await assembly.create(wait=True, resumable=False)
409408

410-
self.assertIs(response, plain_response)
411-
self.assertEqual(response.data, "plain assembly response")
412409
post_mock.assert_awaited_once()
413-
get_mock.assert_not_awaited()
414-
sleep_mock.assert_not_awaited()
415410

416-
async def test_async_assembly_wait_returns_plain_text_poll_response_without_crashing(self):
411+
async def test_async_assembly_wait_raises_on_plain_text_poll_response(self):
417412
initial_response = Response(
418413
data={
419414
"ok": "ASSEMBLY_PROCESSING",
@@ -435,10 +430,9 @@ async def test_async_assembly_wait_returns_plain_text_poll_response_without_cras
435430
with mock.patch.object(client.request, "post", new=mock.AsyncMock(return_value=initial_response)) as post_mock:
436431
with mock.patch.object(client, "get_assembly", new=mock.AsyncMock(return_value=plain_response)) as get_mock:
437432
with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock) as sleep_mock:
438-
response = await assembly.create(wait=True, resumable=False)
433+
with self.assertRaises(RuntimeError):
434+
await assembly.create(wait=True, resumable=False)
439435

440-
self.assertIs(response, plain_response)
441-
self.assertEqual(response.data, "plain assembly response")
442436
post_mock.assert_awaited_once()
443437
get_mock.assert_awaited_once_with(
444438
assembly_url=f"{self.server.base_url}/assemblies/assembly-123"
@@ -988,10 +982,18 @@ async def test_async_assembly_wait_returns_last_poll_response_when_budget_exhaus
988982

989983
self.assertEqual(response.data["error"], "RATE_LIMIT_REACHED")
990984
post_mock.assert_awaited_once()
991-
get_mock.assert_awaited_once_with(
992-
assembly_url=f"{self.server.base_url}/assemblies/assembly-123"
985+
self.assertEqual(
986+
get_mock.await_args_list,
987+
[
988+
mock.call(
989+
assembly_url=f"{self.server.base_url}/assemblies/assembly-123"
990+
),
991+
mock.call(
992+
assembly_url=f"{self.server.base_url}/assemblies/assembly-123"
993+
),
994+
],
993995
)
994-
self.assertEqual(sleep_mock.await_args_list, [mock.call(0)])
996+
self.assertEqual(sleep_mock.await_args_list, [mock.call(0), mock.call(0)])
995997

996998
async def test_async_assembly_non_resumable_rate_limit_rewinds_files_for_retry(self):
997999
reads = []
@@ -1066,6 +1068,25 @@ async def test_async_request_uses_connect_and_read_timeouts_for_uploads(self):
10661068
self.assertIsNone(timeout.sock_read)
10671069
self.assertEqual(session.calls[0][1]["data"]._fields[2][1]["Content-Type"], "image/jpeg")
10681070

1071+
async def test_async_request_filters_none_and_lowercases_booleans_in_extra_data(self):
1072+
session = _RecordingSession({"ok": "ASSEMBLY_COMPLETED"})
1073+
client = AsyncTransloadit("key", "secret", service=self.server.base_url, session=session)
1074+
upload = io.BytesIO(b"payload")
1075+
upload.name = "clip.jpg"
1076+
1077+
response = await client.request.post(
1078+
"/assemblies",
1079+
data={"foo": "bar"},
1080+
extra_data={"enabled": True, "skip": None},
1081+
files={"file": upload},
1082+
)
1083+
1084+
self.assertEqual(response.data["ok"], "ASSEMBLY_COMPLETED")
1085+
fields = {field[0]["name"]: field for field in session.calls[0][1]["data"]._fields}
1086+
self.assertIn("enabled", fields)
1087+
self.assertNotIn("skip", fields)
1088+
self.assertEqual(fields["enabled"][2], "true")
1089+
10691090
async def test_async_request_uses_filename_fallback_for_trailing_slash_stream_name(self):
10701091
session = _RecordingSession({"ok": "ASSEMBLY_COMPLETED"})
10711092
client = AsyncTransloadit("key", "secret", service=self.server.base_url, session=session)

transloadit/async_assembly.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ async def create(self, wait=False, resumable=True, retries=3):
101101

102102
response_data = self._response_data(response)
103103
if response_data is None:
104+
if response.status_code >= 400:
105+
raise RuntimeError(f"Unexpected non-JSON response ({response.status_code}).")
104106
return response
105107

106108
if self._rate_limit_reached(response_data):
@@ -130,20 +132,23 @@ async def create(self, wait=False, resumable=True, retries=3):
130132

131133
poll_response = response
132134
poll_data = response_data
133-
remaining_polls = poll_retries
135+
remaining_rate_limit_retries = poll_retries
134136
while not self._assembly_finished(poll_data):
135-
if remaining_polls <= 0:
136-
return poll_response
137+
if self._rate_limit_reached(poll_data):
138+
if remaining_rate_limit_retries <= 0:
139+
return poll_response
140+
remaining_rate_limit_retries -= 1
137141
sleep_time = poll_data.get("info", {}).get("retryIn", 1)
138142
await asyncio.sleep(sleep_time)
139143
poll_response = await self.transloadit.get_assembly(
140144
assembly_url=assembly_url or poll_data.get("assembly_ssl_url")
141145
)
142146
poll_data = self._response_data(poll_response)
143147
if poll_data is None:
148+
if poll_response.status_code >= 400:
149+
raise RuntimeError(f"Unexpected non-JSON response ({poll_response.status_code}).")
144150
return poll_response
145151
assembly_url = poll_data.get("assembly_ssl_url") or assembly_url
146-
remaining_polls -= 1
147152

148153
return poll_response
149154

transloadit/async_request.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def __init__(self, transloadit, session=None):
4141
def session(self):
4242
return self._session
4343

44+
def _headers(self):
45+
return dict(self.HEADERS)
46+
4447
async def _ensure_session(self):
4548
async with self._session_lock:
4649
if self._session is None:
@@ -66,7 +69,15 @@ def _timeout(self, files=False):
6669
)
6770

6871
def _normalize_payload(self, data):
69-
return {key: str(value) for key, value in data.items()}
72+
normalized = {}
73+
for key, value in data.items():
74+
if value is None:
75+
continue
76+
if isinstance(value, bool):
77+
normalized[key] = "true" if value else "false"
78+
else:
79+
normalized[key] = str(value)
80+
return normalized
7081

7182
async def _read_response_data(self, response):
7283
try:
@@ -82,7 +93,7 @@ async def get(self, path, params=None):
8293
async with session.get(
8394
self._get_full_url(path),
8495
params=self._to_payload(params),
85-
headers=self.HEADERS,
96+
headers=self._headers(),
8697
timeout=self._timeout(),
8798
) as response:
8899
return Response(
@@ -116,7 +127,7 @@ async def post(self, path, data=None, extra_data=None, files=None):
116127
async with session.post(
117128
self._get_full_url(path),
118129
data=payload,
119-
headers=self.HEADERS,
130+
headers=self._headers(),
120131
timeout=self._timeout(files=bool(files)),
121132
) as response:
122133
return Response(
@@ -134,7 +145,7 @@ async def put(self, path, data=None):
134145
async with session.put(
135146
self._get_full_url(path),
136147
data=data,
137-
headers=self.HEADERS,
148+
headers=self._headers(),
138149
timeout=self._timeout(),
139150
) as response:
140151
return Response(
@@ -152,7 +163,7 @@ async def delete(self, path, data=None):
152163
async with session.delete(
153164
self._get_full_url(path),
154165
data=data,
155-
headers=self.HEADERS,
166+
headers=self._headers(),
156167
timeout=self._timeout(),
157168
) as response:
158169
return Response(

0 commit comments

Comments
 (0)