Skip to content

Commit 88807ad

Browse files
committed
Harden async retry and service URLs
1 parent 7f8fc8c commit 88807ad

6 files changed

Lines changed: 89 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
### 2.0.0 / 2026-05-20 ###
22
* **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.
33
* 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.
4+
* 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.
45
* Raised the runtime HTTP stack to patched versions by requiring `requests` 2.33+ and adding an explicit `urllib3` 2.7+ floor.
56
* Updated development and documentation tooling, including `pytest` 9.0.3, `Sphinx` 9.1, `sphinx-autobuild` 2025.8, `coverage` 7.14, `tox` 4.54, and `requests-mock` 1.12.
67
* Updated CI and local Docker test coverage to a representative Python 3.12, 3.13, and 3.14 matrix.

tests/test_async_client.py

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,22 @@ async def test_async_client_normalizes_service_and_rejects_missing_ids(self):
365365
with self.assertRaises(ValueError):
366366
await client.cancel_assembly()
367367

368-
with self.assertRaises(ValueError):
369-
await client.get_assembly(assembly_url="https://example.com/assemblies/abc123")
370-
371-
with self.assertRaises(ValueError):
372-
await client.cancel_assembly(assembly_url="https://example.com/assemblies/abc123")
368+
external_session = _RecordingSession({"ok": "ASSEMBLY_COMPLETED"})
369+
external_client = AsyncTransloadit(
370+
"key",
371+
"secret",
372+
service="https://api2.transloadit.com",
373+
session=external_session,
374+
)
375+
await external_client.get_assembly(assembly_url="https://example.com/assemblies/abc123")
376+
await external_client.cancel_assembly(assembly_url="https://example.com/assemblies/abc123")
377+
self.assertEqual(
378+
[call[0] for call in external_session.calls],
379+
[
380+
"https://example.com/assemblies/abc123",
381+
"https://example.com/assemblies/abc123",
382+
],
383+
)
373384

374385
transloadit_session = _RecordingSession({"ok": "ASSEMBLY_COMPLETED"})
375386
transloadit_client = AsyncTransloadit(
@@ -1037,6 +1048,42 @@ def uploader(self, **kwargs):
10371048
post_mock.assert_awaited_once()
10381049
self.assertEqual(calls, [])
10391050

1051+
async def test_async_assembly_resumable_response_allows_configured_service_tus_url(self):
1052+
calls = []
1053+
1054+
class _TusClient:
1055+
def __init__(self, tus_url):
1056+
calls.append(("client", tus_url))
1057+
1058+
def uploader(self, **kwargs):
1059+
calls.append(("upload", kwargs["metadata"]))
1060+
return self
1061+
1062+
def upload(self):
1063+
calls.append(("uploaded",))
1064+
1065+
async with AsyncTransloadit("key", "secret", service=self.server.base_url) as client:
1066+
assembly = client.new_assembly()
1067+
assembly.add_file(io.BytesIO(b"payload"))
1068+
1069+
response = Response(
1070+
data={
1071+
"assembly_ssl_url": f"{self.server.base_url}/assemblies/assembly-123",
1072+
"tus_url": "https://example.com/uploads",
1073+
},
1074+
status_code=200,
1075+
headers={},
1076+
)
1077+
1078+
with mock.patch.object(client.request, "post", new=mock.AsyncMock(return_value=response)) as post_mock:
1079+
with mock.patch("transloadit.async_assembly.tus.TusClient", new=_TusClient):
1080+
await assembly.create(resumable=True)
1081+
1082+
post_mock.assert_awaited_once()
1083+
self.assertEqual(calls[0], ("client", "https://example.com/uploads"))
1084+
self.assertEqual(calls[1][0], "upload")
1085+
self.assertEqual(calls[2], ("uploaded",))
1086+
10401087
async def test_async_assembly_wait_returns_response_without_assembly_url(self):
10411088
incomplete_response = Response(
10421089
data={"ok": "ASSEMBLY_PROCESSING"},
@@ -1334,6 +1381,18 @@ async def test_async_assembly_rate_limit_ignores_malformed_error_values(self):
13341381
self.assertFalse(assembly._rate_limit_reached({"error": ["RATE_LIMIT_REACHED"]}))
13351382
self.assertFalse(assembly._rate_limit_reached({"error": {"code": "RATE_LIMIT_REACHED"}}))
13361383

1384+
async def test_async_assembly_retry_delay_sanitizes_response_info(self):
1385+
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
1386+
assembly = client.new_assembly()
1387+
1388+
self.assertEqual(assembly._retry_delay({}), 1)
1389+
self.assertEqual(assembly._retry_delay({"info": None}), 1)
1390+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": "bad"}}), 1)
1391+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": float("nan")}}), 1)
1392+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": -2}}), 0)
1393+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": 0.25}}), 0.25)
1394+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": 9999}}), 60)
1395+
13371396
async def test_async_tus_upload_cancellation_returns_before_thread_finishes(self):
13381397
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
13391398
assembly = client.new_assembly()

tests/test_request.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def test_payload_preserves_custom_auth_constraints(self):
5656
self.assertEqual(params["auth"]["max_size"], 1024)
5757
self.assertEqual(params["auth"]["referer"], "https://example.com")
5858

59-
def test_full_url_rejects_external_absolute_urls(self):
59+
def test_full_url_allows_explicit_absolute_urls(self):
6060
self.assertEqual(
6161
self.request._get_full_url(f"{self.transloadit.service}/foo"),
6262
f"{self.transloadit.service}/foo",
@@ -65,8 +65,10 @@ def test_full_url_rejects_external_absolute_urls(self):
6565
self.request._get_full_url("https://api2-region.transloadit.com/foo"),
6666
"https://api2-region.transloadit.com/foo",
6767
)
68-
with self.assertRaises(ValueError):
69-
self.request._get_full_url("https://example.com/foo")
68+
self.assertEqual(
69+
self.request._get_full_url("https://example.com/foo"),
70+
"https://example.com/foo",
71+
)
7072

7173
@requests_mock.Mocker()
7274
def test_put(self, mock):

transloadit/async_assembly.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
2+
import math
23

34
from tusclient import client as tus
45

56
from . import optionbuilder
67
from .async_request import _get_upload_filename
78

9+
MAX_RETRY_DELAY_SECONDS = 60
10+
811

912
class AsyncAssembly(optionbuilder.OptionBuilder):
1013
"""
@@ -78,6 +81,8 @@ def _do_tus_upload(self, assembly_url, tus_url, retries):
7881
).upload()
7982

8083
async def _do_tus_upload_async(self, assembly_url, tus_url, retries):
84+
# tuspy is synchronous: cancelling this awaiter cannot stop a worker thread already in flight.
85+
# Returning cancellation promptly is safer than making callers wait on a stalled sync upload.
8186
await asyncio.to_thread(self._do_tus_upload, assembly_url, tus_url, retries)
8287

8388
async def create(self, wait=False, resumable=True, retries=3):
@@ -116,7 +121,7 @@ async def create(self, wait=False, resumable=True, retries=3):
116121
)
117122
if not resumable:
118123
self._rewind_files(file_positions)
119-
await asyncio.sleep(response_data.get("info", {}).get("retryIn", 1))
124+
await asyncio.sleep(self._retry_delay(response_data))
120125
retries -= 1
121126
continue
122127
return response
@@ -145,7 +150,7 @@ async def create(self, wait=False, resumable=True, retries=3):
145150
if remaining_rate_limit_retries <= 0:
146151
return poll_response
147152
remaining_rate_limit_retries -= 1
148-
sleep_time = poll_data.get("info", {}).get("retryIn", 1)
153+
sleep_time = self._retry_delay(poll_data)
149154
await asyncio.sleep(sleep_time)
150155
poll_response = await self.transloadit.get_assembly(
151156
assembly_url=assembly_url
@@ -179,3 +184,15 @@ def _rate_limit_reached(self, response_data):
179184
"RATE_LIMIT_REACHED",
180185
"ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED",
181186
}
187+
188+
def _retry_delay(self, response_data):
189+
info = response_data.get("info")
190+
if not isinstance(info, dict):
191+
return 1
192+
try:
193+
delay = float(info.get("retryIn", 1))
194+
except (TypeError, ValueError):
195+
return 1
196+
if not math.isfinite(delay):
197+
return 1
198+
return min(max(delay, 0), MAX_RETRY_DELAY_SECONDS)

transloadit/async_request.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import json
99
from types import MappingProxyType
1010
from datetime import datetime, timedelta, timezone
11-
from urllib.parse import urlparse
1211

1312
import aiohttp
1413
from requests.structures import CaseInsensitiveDict
@@ -19,10 +18,6 @@
1918
TIMEOUT = 60
2019

2120

22-
def _is_transloadit_host(hostname):
23-
return hostname == "transloadit.com" or hostname.endswith(".transloadit.com")
24-
25-
2621
def _get_upload_filename(file_stream, fallback):
2722
name = getattr(file_stream, "name", None)
2823
if isinstance(name, (bytes, os.PathLike)):
@@ -271,15 +266,5 @@ def _sign_data(self, message):
271266

272267
def _get_full_url(self, url):
273268
if url.startswith(("http://", "https://")):
274-
service = urlparse(self.transloadit.service)
275-
target = urlparse(url)
276-
same_origin = (target.scheme, target.netloc) == (service.scheme, service.netloc)
277-
transloadit_origin = (
278-
target.scheme == service.scheme
279-
and _is_transloadit_host(service.hostname or "")
280-
and _is_transloadit_host(target.hostname or "")
281-
)
282-
if not (same_origin or transloadit_origin):
283-
raise ValueError("Absolute API URLs must use the configured Transloadit service origin.")
284269
return url
285270
return self.transloadit.service + url

transloadit/request.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
import copy
55
from datetime import datetime, timedelta, timezone
6-
from urllib.parse import urlparse
76

87
import requests
98

@@ -12,11 +11,6 @@
1211

1312
TIMEOUT = 60
1413

15-
16-
def _is_transloadit_host(hostname):
17-
return hostname == "transloadit.com" or hostname.endswith(".transloadit.com")
18-
19-
2014
class Request:
2115
"""
2216
Transloadit tailored HTTP Request object.
@@ -136,16 +130,6 @@ def _sign_data(self, message):
136130

137131
def _get_full_url(self, url):
138132
if url.startswith(("http://", "https://")):
139-
service = urlparse(self.transloadit.service)
140-
target = urlparse(url)
141-
same_origin = (target.scheme, target.netloc) == (service.scheme, service.netloc)
142-
transloadit_origin = (
143-
target.scheme == service.scheme
144-
and _is_transloadit_host(service.hostname or "")
145-
and _is_transloadit_host(target.hostname or "")
146-
)
147-
if not (same_origin or transloadit_origin):
148-
raise ValueError("Absolute API URLs must use the configured Transloadit service origin.")
149133
return url
150134
else:
151135
return self.transloadit.service + url

0 commit comments

Comments
 (0)