Skip to content

Commit 6d522c7

Browse files
authored
Merge branch 'main' into deanq/sls-367-volumecache-adaptive-transport
2 parents 49ff131 + b510d2a commit 6d522c7

6 files changed

Lines changed: 999 additions & 104 deletions

File tree

‎docs/serverless/worker_fitness_checks.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ User-registered checks via `@register_fitness_check` still run regardless of the
407407

408408
- Fitness checks run **only once at worker startup**
409409
- They run **before the first job is processed**
410-
- They run **only on the actual RunPod serverless platform**
410+
- They run **only on the actual Runpod serverless platform**
411411
- Local development and testing modes skip fitness checks
412412

413413
### Execution Order
@@ -653,7 +653,7 @@ if __name__ == "__main__":
653653

654654
### Checks Aren't Running
655655

656-
Fitness checks only run on the actual RunPod serverless platform, not locally. To debug locally:
656+
Fitness checks only run on the actual Runpod serverless platform, not locally. To debug locally:
657657

658658
```python
659659
# Manually test your fitness checks

‎requirements.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
aiohttp[speedups] >= 3.14.1
1+
aiohttp[speedups] >= 3.14.3
22
aiohttp-retry >= 2.9.1
33

44
backoff >= 2.2.1
55
boto3 >= 1.43.51
66
click >= 8.4.2
77
colorama >= 0.4.6, < 0.4.7
88
cryptography >= 49.0.0
9-
fastapi[all] >= 0.139.2
9+
fastapi[all] >= 0.140.0
1010
paramiko >= 5.0.0
1111
prettytable >= 3.18.0
1212
psutil >= 7.2.2

‎runpod/serverless/utils/rp_download.py‎

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
import backoff
1717
from requests import RequestException
1818

19-
from runpod.http_client import SyncClientSession
19+
from runpod.serverless.utils.rp_ssrf import (
20+
iter_content_capped,
21+
max_download_bytes,
22+
safe_get,
23+
)
2024

2125
HEADERS = {"User-Agent": "runpod-python/0.0.0 (https://runpod.io; support@runpod.io)"}
2226

@@ -33,6 +37,20 @@ def calculate_chunk_size(file_size: int) -> int:
3337
return 1024 * 1024 * 10 # 10 MB
3438

3539

40+
def parse_content_length(headers) -> int:
41+
"""
42+
Reads Content-Length for chunk sizing, tolerating a missing or malformed value.
43+
44+
The header is remote-controlled, so a non-integer value must not abort an
45+
otherwise valid download; 0 simply yields the smallest chunk size. The real
46+
size limit is enforced while streaming by iter_content_capped().
47+
"""
48+
try:
49+
return int(headers.get("Content-Length", 0))
50+
except (TypeError, ValueError):
51+
return 0
52+
53+
3654
def extract_disposition_params(content_disposition: str) -> Dict[str, str]:
3755
parts = (p.strip() for p in content_disposition.split(";"))
3856

@@ -57,7 +75,7 @@ def download_files_from_urls(job_id: str, urls: Union[str, List[str]]) -> List[s
5775

5876
@backoff.on_exception(backoff.expo, RequestException, max_tries=3)
5977
def download_file(url: str, path_to_save: str) -> str:
60-
with SyncClientSession().get(url, headers=HEADERS, stream=True, timeout=5) as response:
78+
with safe_get(url, stream=True, timeout=5, headers=HEADERS) as response:
6179
response.raise_for_status()
6280
content_disposition = response.headers.get("Content-Disposition")
6381
file_extension = ""
@@ -69,14 +87,12 @@ def download_file(url: str, path_to_save: str) -> str:
6987
if not file_extension:
7088
file_extension = os.path.splitext(urlparse(url).path)[1]
7189

72-
file_size = int(response.headers.get("Content-Length", 0))
73-
chunk_size = calculate_chunk_size(file_size)
90+
chunk_size = calculate_chunk_size(parse_content_length(response.headers))
7491

75-
# write the content in chunks to the file
92+
# write the content in chunks to the file, aborting past the size cap
7693
with open(path_to_save + file_extension, "wb") as file_path:
77-
for chunk in response.iter_content(chunk_size=chunk_size):
78-
if chunk: # filter out keep-alive chunks
79-
file_path.write(chunk)
94+
for chunk in iter_content_capped(response, chunk_size, max_download_bytes()):
95+
file_path.write(chunk)
8096

8197
return file_extension
8298

@@ -117,27 +133,35 @@ def file(file_url: str) -> dict:
117133
"""
118134
os.makedirs("job_files", exist_ok=True)
119135

120-
download_response = SyncClientSession().get(file_url, headers=HEADERS, timeout=30)
136+
with safe_get(file_url, stream=True, timeout=30, headers=HEADERS) as download_response:
137+
# Fail on an error response rather than saving the error page as the
138+
# file; for a .zip that body would go straight to the extractor.
139+
download_response.raise_for_status()
140+
141+
content_disposition = download_response.headers.get("Content-Disposition")
121142

122-
content_disposition = download_response.headers.get("Content-Disposition")
143+
original_file_name = ""
144+
if content_disposition:
145+
params = extract_disposition_params(content_disposition)
123146

124-
original_file_name = ""
125-
if content_disposition:
126-
params = extract_disposition_params(content_disposition)
147+
original_file_name = params.get("filename", "")
127148

128-
original_file_name = params.get("filename", "")
149+
if not original_file_name:
150+
download_path = urlparse(file_url).path
151+
original_file_name = os.path.basename(download_path)
129152

130-
if not original_file_name:
131-
download_path = urlparse(file_url).path
132-
original_file_name = os.path.basename(download_path)
153+
file_type = os.path.splitext(original_file_name)[1].replace(".", "")
133154

134-
file_type = os.path.splitext(original_file_name)[1].replace(".", "")
155+
file_name = f"{uuid.uuid4()}"
135156

136-
file_name = f"{uuid.uuid4()}"
157+
output_file_path = os.path.join("job_files", f"{file_name}.{file_type}")
137158

138-
output_file_path = os.path.join("job_files", f"{file_name}.{file_type}")
139-
with open(output_file_path, "wb") as output_file:
140-
output_file.write(download_response.content)
159+
# Stream to disk in chunks (aborting past the size cap) instead of
160+
# buffering the entire untrusted body in memory.
161+
chunk_size = calculate_chunk_size(parse_content_length(download_response.headers))
162+
with open(output_file_path, "wb") as output_file:
163+
for chunk in iter_content_capped(download_response, chunk_size, max_download_bytes()):
164+
output_file.write(chunk)
141165

142166
if file_type == "zip":
143167
unzipped_directory = os.path.join("job_files", file_name)

0 commit comments

Comments
 (0)