Skip to content

Commit 5750d70

Browse files
committed
Validate async TUS URLs and retry delays
1 parent 7f8fc8c commit 5750d70

6 files changed

Lines changed: 91 additions & 33 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 rejecting absolute API URLs outside Transloadit origins.
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,37 @@ def uploader(self, **kwargs):
10371037
post_mock.assert_awaited_once()
10381038
self.assertEqual(calls, [])
10391039

1040+
async def test_async_assembly_resumable_response_rejects_external_tus_url(self):
1041+
calls = []
1042+
1043+
class _TusClient:
1044+
def __init__(self, tus_url):
1045+
calls.append(("client", tus_url))
1046+
1047+
def uploader(self, **kwargs):
1048+
raise AssertionError("TUS upload should not start with an external upload URL")
1049+
1050+
async with AsyncTransloadit("key", "secret", service=self.server.base_url) as client:
1051+
assembly = client.new_assembly()
1052+
assembly.add_file(io.BytesIO(b"payload"))
1053+
1054+
response = Response(
1055+
data={
1056+
"assembly_ssl_url": f"{self.server.base_url}/assemblies/assembly-123",
1057+
"tus_url": "https://example.com/uploads",
1058+
},
1059+
status_code=200,
1060+
headers={},
1061+
)
1062+
1063+
with mock.patch.object(client.request, "post", new=mock.AsyncMock(return_value=response)) as post_mock:
1064+
with mock.patch("transloadit.async_assembly.tus.TusClient", new=_TusClient):
1065+
with self.assertRaises(ValueError):
1066+
await assembly.create(resumable=True)
1067+
1068+
post_mock.assert_awaited_once()
1069+
self.assertEqual(calls, [])
1070+
10401071
async def test_async_assembly_wait_returns_response_without_assembly_url(self):
10411072
incomplete_response = Response(
10421073
data={"ok": "ASSEMBLY_PROCESSING"},
@@ -1334,6 +1365,18 @@ async def test_async_assembly_rate_limit_ignores_malformed_error_values(self):
13341365
self.assertFalse(assembly._rate_limit_reached({"error": ["RATE_LIMIT_REACHED"]}))
13351366
self.assertFalse(assembly._rate_limit_reached({"error": {"code": "RATE_LIMIT_REACHED"}}))
13361367

1368+
async def test_async_assembly_retry_delay_sanitizes_response_info(self):
1369+
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
1370+
assembly = client.new_assembly()
1371+
1372+
self.assertEqual(assembly._retry_delay({}), 1)
1373+
self.assertEqual(assembly._retry_delay({"info": None}), 1)
1374+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": "bad"}}), 1)
1375+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": float("nan")}}), 1)
1376+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": -2}}), 0)
1377+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": 0.25}}), 0.25)
1378+
self.assertEqual(assembly._retry_delay({"info": {"retryIn": 9999}}), 60)
1379+
13371380
async def test_async_tus_upload_cancellation_returns_before_thread_finishes(self):
13381381
client = AsyncTransloadit("key", "secret", service=self.server.base_url)
13391382
assembly = client.new_assembly()

transloadit/async_assembly.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +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
8+
from .url_utils import validate_absolute_api_url
9+
10+
MAX_RETRY_DELAY_SECONDS = 60
711

812

913
class AsyncAssembly(optionbuilder.OptionBuilder):
@@ -62,6 +66,8 @@ def _rewind_files(self, positions):
6266
raise RuntimeError(f"Unable to rewind file stream {key!r}.") from exc
6367

6468
def _do_tus_upload(self, assembly_url, tus_url, retries):
69+
validate_absolute_api_url(self.transloadit.service, assembly_url)
70+
validate_absolute_api_url(self.transloadit.service, tus_url)
6571
tus_client = tus.TusClient(tus_url)
6672
for key, file_stream in self.files.items():
6773
filename = _get_upload_filename(file_stream, key)
@@ -78,6 +84,8 @@ def _do_tus_upload(self, assembly_url, tus_url, retries):
7884
).upload()
7985

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

8391
async def create(self, wait=False, resumable=True, retries=3):
@@ -116,7 +124,7 @@ async def create(self, wait=False, resumable=True, retries=3):
116124
)
117125
if not resumable:
118126
self._rewind_files(file_positions)
119-
await asyncio.sleep(response_data.get("info", {}).get("retryIn", 1))
127+
await asyncio.sleep(self._retry_delay(response_data))
120128
retries -= 1
121129
continue
122130
return response
@@ -145,7 +153,7 @@ async def create(self, wait=False, resumable=True, retries=3):
145153
if remaining_rate_limit_retries <= 0:
146154
return poll_response
147155
remaining_rate_limit_retries -= 1
148-
sleep_time = poll_data.get("info", {}).get("retryIn", 1)
156+
sleep_time = self._retry_delay(poll_data)
149157
await asyncio.sleep(sleep_time)
150158
poll_response = await self.transloadit.get_assembly(
151159
assembly_url=assembly_url
@@ -179,3 +187,15 @@ def _rate_limit_reached(self, response_data):
179187
"RATE_LIMIT_REACHED",
180188
"ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED",
181189
}
190+
191+
def _retry_delay(self, response_data):
192+
info = response_data.get("info")
193+
if not isinstance(info, dict):
194+
return 1
195+
try:
196+
delay = float(info.get("retryIn", 1))
197+
except (TypeError, ValueError):
198+
return 1
199+
if not math.isfinite(delay):
200+
return 1
201+
return min(max(delay, 0), MAX_RETRY_DELAY_SECONDS)

transloadit/async_request.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,17 @@
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
1514

1615
from . import __version__
1716
from .response import Response
17+
from .url_utils import validate_absolute_api_url
1818

1919
TIMEOUT = 60
2020

2121

22-
def _is_transloadit_host(hostname):
23-
return hostname == "transloadit.com" or hostname.endswith(".transloadit.com")
24-
25-
2622
def _get_upload_filename(file_stream, fallback):
2723
name = getattr(file_stream, "name", None)
2824
if isinstance(name, (bytes, os.PathLike)):
@@ -271,15 +267,6 @@ def _sign_data(self, message):
271267

272268
def _get_full_url(self, url):
273269
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.")
270+
validate_absolute_api_url(self.transloadit.service, url)
284271
return url
285272
return self.transloadit.service + url

transloadit/request.py

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

87
import requests
98

109
from .response import as_response
1110
from . import __version__
11+
from .url_utils import validate_absolute_api_url
1212

1313
TIMEOUT = 60
1414

15-
16-
def _is_transloadit_host(hostname):
17-
return hostname == "transloadit.com" or hostname.endswith(".transloadit.com")
18-
19-
2015
class Request:
2116
"""
2217
Transloadit tailored HTTP Request object.
@@ -136,16 +131,7 @@ def _sign_data(self, message):
136131

137132
def _get_full_url(self, url):
138133
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.")
134+
validate_absolute_api_url(self.transloadit.service, url)
149135
return url
150136
else:
151137
return self.transloadit.service + url

transloadit/url_utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from urllib.parse import urlparse
2+
3+
4+
def is_transloadit_host(hostname):
5+
return hostname == "transloadit.com" or hostname.endswith(".transloadit.com")
6+
7+
8+
def validate_absolute_api_url(service_url, target_url):
9+
if not target_url.startswith(("http://", "https://")):
10+
return
11+
12+
service = urlparse(service_url)
13+
target = urlparse(target_url)
14+
same_origin = (target.scheme, target.netloc) == (service.scheme, service.netloc)
15+
transloadit_origin = (
16+
target.scheme == service.scheme
17+
and is_transloadit_host(service.hostname or "")
18+
and is_transloadit_host(target.hostname or "")
19+
)
20+
if not (same_origin or transloadit_origin):
21+
raise ValueError("Absolute API URLs must use the configured Transloadit service or regional Transloadit origin.")

0 commit comments

Comments
 (0)