Skip to content

Commit 8090d14

Browse files
committed
Harden sync assembly retries and generated docs
1 parent 3cd48ac commit 8090d14

7 files changed

Lines changed: 371 additions & 119 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
### 2.0.0 / 2026-05-20 ###
2-
* @TODO before merging: expand the SDK surface so this release supports all Transloadit API endpoints, and update these release notes with the final endpoint coverage.
32
* **Breaking Change**: Raised the supported Python runtime floor from 3.9+ to 3.12+ so the SDK no longer has to retain vulnerable locked dependency versions for EOL Python 3.9 or depend on tooling lines that are already dropping older runtime support.
3+
* Expanded low-level API endpoint coverage for Assemblies, Assembly Notifications, Templates, Template Credentials, Priority Job Slots, and Billing, with sync and async methods generated from the API2 endpoint contract.
44
* Added explicit asyncio support with `AsyncTransloadit`, async request/assembly/template helpers, and `asyncio.sleep`-based polling. Resumable uploads stay on the existing TUS client, but run through `asyncio.to_thread()` so the event loop remains responsive instead of pretending the sync uploader is natively async.
5-
* Hardened upload and response edge cases: invalid service URLs and empty template IDs now fail fast, external absolute API URLs are no longer signed, sync TUS uploads now handle nameless streams and submit rate limits before uploading, async form fields match sync boolean serialization, async TUS cancellation waits for worker cleanup, async polling rate-limit retries reset after successful polls, async rate-limit backoff honors server `retryIn`, Smart CDN signing rejects invalid workspace slugs/reserved query keys, and sync non-JSON responses fall back to response text.
5+
* Hardened upload and response edge cases: invalid service URLs and empty template IDs now fail fast, external absolute API URLs are no longer signed, sync TUS uploads now handle nameless streams and submit rate limits before uploading, sync non-resumable retries rewind seekable files and reject non-seekable retry streams, sync/async polling rate-limit retries reset after successful polls and cap repeated rate limits, async form fields match sync boolean serialization, async TUS cancellation waits for worker cleanup, async rate-limit backoff honors server `retryIn`, Smart CDN signing rejects invalid workspace slugs/reserved query keys, and sync non-JSON responses fall back to response text.
66
* Hardened sync and async request handling by preserving custom `auth` constraints, quoting path IDs, and keeping explicit/custom service URLs compatible with local, CI, and [Transloadit Gateway](https://github.com/transloadit/gateway) deployments.
77
* Fixed sync and async template creation to send the current API `template` payload shape.
88
* Raised the runtime HTTP stack to patched versions by requiring `requests` 2.33+ and adding an explicit `urllib3` 2.7+ floor.

tests/test_api_url.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import unittest
2+
3+
from transloadit.api_url import should_sign_api_url
4+
5+
6+
class ApiUrlTest(unittest.TestCase):
7+
def test_should_sign_same_service_url_with_different_host_casing(self):
8+
self.assertTrue(
9+
should_sign_api_url(
10+
"https://API2.transloadit.com/assemblies/abc",
11+
"https://api2.transloadit.com",
12+
)
13+
)
14+

tests/test_assembly.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,208 @@ def test_save_resumable_retries_rate_limit_before_tus_upload(self):
130130
"https://api2.example/assemblies/abc",
131131
)
132132
uploader.upload.assert_called_once()
133+
134+
def test_save_non_resumable_rewinds_seekable_files_before_retry(self):
135+
upload = io.BytesIO(b"payload")
136+
rate_limited = Response(
137+
data={
138+
"error": "RATE_LIMIT_REACHED",
139+
"info": {"retryIn": 0},
140+
},
141+
status_code=200,
142+
headers={},
143+
)
144+
success = Response(
145+
data={"ok": "ASSEMBLY_COMPLETED", "assembly_id": "abcdef45673"},
146+
status_code=200,
147+
headers={},
148+
)
149+
seen_positions = []
150+
self.assembly.add_file(upload, "payload_field")
151+
152+
def post_side_effect(*args, **kwargs):
153+
seen_positions.append(kwargs["files"]["payload_field"].tell())
154+
kwargs["files"]["payload_field"].seek(0, io.SEEK_END)
155+
return rate_limited if len(seen_positions) == 1 else success
156+
157+
with mock.patch.object(self.transloadit.request, "post", side_effect=post_side_effect):
158+
with mock.patch("transloadit.assembly.sleep"):
159+
assembly = self.assembly.create(resumable=False, retries=1)
160+
161+
self.assertEqual(assembly.data["ok"], "ASSEMBLY_COMPLETED")
162+
self.assertEqual(seen_positions, [0, 0])
163+
164+
def test_save_non_resumable_rejects_non_seekable_file_retry(self):
165+
class NonSeekableFile:
166+
pass
167+
168+
rate_limited = Response(
169+
data={
170+
"error": "RATE_LIMIT_REACHED",
171+
"info": {"retryIn": 0},
172+
},
173+
status_code=200,
174+
headers={},
175+
)
176+
self.assembly.add_file(NonSeekableFile(), "payload_field")
177+
178+
with mock.patch.object(self.transloadit.request, "post", return_value=rate_limited):
179+
with self.assertRaises(RuntimeError):
180+
self.assembly.create(resumable=False, retries=1)
181+
182+
def test_save_wait_raises_on_plain_text_create_response(self):
183+
plain_response = Response(
184+
data="plain assembly response",
185+
status_code=502,
186+
headers={"X-Route": "plain"},
187+
)
188+
189+
with mock.patch.object(self.transloadit.request, "post", return_value=plain_response):
190+
with self.assertRaises(RuntimeError):
191+
self.assembly.create(wait=True, resumable=False)
192+
193+
def test_save_returns_plain_text_success_without_wait(self):
194+
plain_response = Response(
195+
data="plain assembly response",
196+
status_code=200,
197+
headers={"X-Route": "plain"},
198+
)
199+
200+
with mock.patch.object(self.transloadit.request, "post", return_value=plain_response):
201+
response = self.assembly.create(wait=False, resumable=False)
202+
203+
self.assertIs(response, plain_response)
204+
205+
def test_save_resumable_plain_text_response_raises_before_tus_upload(self):
206+
plain_response = Response(
207+
data="plain assembly response",
208+
status_code=200,
209+
headers={"X-Route": "plain"},
210+
)
211+
self.assembly.add_file(io.BytesIO(b"payload"), "payload_field")
212+
213+
with mock.patch.object(self.transloadit.request, "post", return_value=plain_response):
214+
with mock.patch("transloadit.assembly.tus.TusClient") as tus_client:
215+
with self.assertRaises(RuntimeError):
216+
self.assembly.create(resumable=True)
217+
218+
tus_client.assert_not_called()
219+
220+
def test_save_wait_pins_assembly_url_across_poll_rate_limits(self):
221+
initial_response = Response(
222+
data={
223+
"ok": "ASSEMBLY_PROCESSING",
224+
"info": {"retryIn": 0},
225+
"assembly_ssl_url": "https://api2.example/assemblies/abc",
226+
},
227+
status_code=200,
228+
headers={},
229+
)
230+
rate_limited = Response(
231+
data={
232+
"error": "ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED",
233+
"info": {"retryIn": 0},
234+
},
235+
status_code=200,
236+
headers={},
237+
)
238+
completed = Response(
239+
data={
240+
"ok": "ASSEMBLY_COMPLETED",
241+
"assembly_ssl_url": "https://api2.example/assemblies/other",
242+
},
243+
status_code=200,
244+
headers={},
245+
)
246+
247+
with mock.patch.object(self.transloadit.request, "post", return_value=initial_response):
248+
with mock.patch.object(
249+
self.transloadit,
250+
"get_assembly",
251+
side_effect=[rate_limited, completed],
252+
) as get_mock:
253+
with mock.patch("transloadit.assembly.sleep"):
254+
response = self.assembly.create(wait=True, resumable=False, retries=2)
255+
256+
self.assertIs(response, completed)
257+
self.assertEqual(
258+
get_mock.call_args_list,
259+
[
260+
mock.call(assembly_url="https://api2.example/assemblies/abc"),
261+
mock.call(assembly_url="https://api2.example/assemblies/abc"),
262+
],
263+
)
264+
265+
def test_save_wait_does_not_follow_poll_response_assembly_url(self):
266+
initial_response = Response(
267+
data={
268+
"ok": "ASSEMBLY_PROCESSING",
269+
"info": {"retryIn": 0},
270+
"assembly_ssl_url": "https://api2.example/assemblies/abc",
271+
},
272+
status_code=200,
273+
headers={},
274+
)
275+
redirected_processing = Response(
276+
data={
277+
"ok": "ASSEMBLY_PROCESSING",
278+
"info": {"retryIn": 0},
279+
"assembly_ssl_url": "https://example.invalid/assemblies/evil",
280+
},
281+
status_code=200,
282+
headers={},
283+
)
284+
completed = Response(
285+
data={"ok": "ASSEMBLY_COMPLETED", "assembly_id": "abcdef45673"},
286+
status_code=200,
287+
headers={},
288+
)
289+
290+
with mock.patch.object(self.transloadit.request, "post", return_value=initial_response):
291+
with mock.patch.object(
292+
self.transloadit,
293+
"get_assembly",
294+
side_effect=[redirected_processing, completed],
295+
) as get_mock:
296+
with mock.patch("transloadit.assembly.sleep"):
297+
response = self.assembly.create(wait=True, resumable=False, retries=2)
298+
299+
self.assertIs(response, completed)
300+
self.assertEqual(
301+
get_mock.call_args_list,
302+
[
303+
mock.call(assembly_url="https://api2.example/assemblies/abc"),
304+
mock.call(assembly_url="https://api2.example/assemblies/abc"),
305+
],
306+
)
307+
308+
def test_save_wait_caps_poll_rate_limit_retries(self):
309+
initial_response = Response(
310+
data={
311+
"ok": "ASSEMBLY_PROCESSING",
312+
"info": {"retryIn": 0},
313+
"assembly_ssl_url": "https://api2.example/assemblies/abc",
314+
},
315+
status_code=200,
316+
headers={},
317+
)
318+
rate_limited = Response(
319+
data={
320+
"error": "ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED",
321+
"info": {"retryIn": 0},
322+
},
323+
status_code=200,
324+
headers={},
325+
)
326+
327+
with mock.patch.object(self.transloadit.request, "post", return_value=initial_response):
328+
with mock.patch.object(
329+
self.transloadit,
330+
"get_assembly",
331+
return_value=rate_limited,
332+
) as get_mock:
333+
with mock.patch("transloadit.assembly.sleep"):
334+
response = self.assembly.create(wait=True, resumable=False, retries=1)
335+
336+
self.assertIs(response, rate_limited)
337+
self.assertEqual(get_mock.call_count, 2)

transloadit/api_url.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@ def should_sign_api_url(url, service):
3636
# Only same-service URLs and Transloadit API regional hosts may receive auth params.
3737
if (
3838
parsed_url.scheme == parsed_service.scheme
39-
and parsed_url.netloc == parsed_service.netloc
39+
and (parsed_url.hostname or "").lower() == (parsed_service.hostname or "").lower()
40+
and parsed_url.port == parsed_service.port
4041
):
4142
return True
4243

43-
hostname = parsed_url.hostname or ""
44+
hostname = (parsed_url.hostname or "").lower()
4445
return parsed_url.scheme == "https" and (
4546
hostname == "api2.transloadit.com"
4647
or (hostname.startswith("api2-") and hostname.endswith(".transloadit.com"))

0 commit comments

Comments
 (0)