fix: validate complete serverless requests against 10 MB limit

This commit is contained in:
Robert Wojciechowski
2026-10-11 05:52:23 +00:00
parent d9485265ea
commit a281d03c6d
7 changed files with 183 additions and 22 deletions
+39 -13
View File
@@ -6,7 +6,7 @@ import re
import aiohttp
API_BASE = "https://api.runpod.ai/v2"
MAX_REQUEST_BYTES = 10 * 1024 * 1024
MAX_REQUEST_BYTES = 10_000_000
_ID = re.compile(r"[A-Za-z0-9_-]{1,200}\Z")
@@ -18,6 +18,31 @@ class AmbiguousSubmission(RunpodError):
"""Submission may have been accepted: never retry it automatically."""
def serialize_request(workflow, images, timeout_seconds):
"""Serialize and size-check the exact JSON body used by Runpod ``/run``."""
payload = {
"input": {"workflow": workflow, "images": images},
"policy": {
"executionTimeout": timeout_seconds * 1000,
"ttl": timeout_seconds * 1000,
},
}
body = json.dumps(
payload,
allow_nan=False,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
size = len(body)
if size > MAX_REQUEST_BYTES:
raise ValueError(
f"Serialized serverless request body is {size:,} bytes; Runpod allows at most "
f"{MAX_REQUEST_BYTES:,} bytes. Base64-encoded images add about 33% over raw "
"image bytes; reduce image or workflow size."
)
return body
def checked_id(value):
if not isinstance(value, str) or not _ID.fullmatch(value):
raise ValueError("Invalid Runpod endpoint or job ID")
@@ -32,19 +57,25 @@ class RunpodClient:
raise ValueError("A valid Runpod API key is required")
self._api_key = api_key
async def _request(self, method, path, payload=None):
async def _request(self, method, path, payload=None, request_body=None):
# Base origin is code-owned, never a user-supplied URL. Do not follow redirects
# with credentials, or include response bodies/transport errors in exceptions.
url = f"{API_BASE}/{self.endpoint_id}/{path}"
attempts = 3 if method == "GET" else 1
for attempt in range(attempts):
try:
request_kwargs = {
"headers": {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"},
"timeout": aiohttp.ClientTimeout(total=30),
"allow_redirects": False,
}
if request_body is None:
request_kwargs["json"] = payload
else:
request_kwargs["data"] = request_body
async with self.session.request(
method, url,
headers={"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"},
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
allow_redirects=False,
**request_kwargs,
) as response:
status = response.status
if status != 200:
@@ -78,13 +109,8 @@ class RunpodClient:
return await self._request("GET", "health")
async def submit(self, workflow, images, timeout_seconds):
payload = {
"input": {"workflow": workflow, "images": images},
"policy": {"executionTimeout": timeout_seconds * 1000, "ttl": timeout_seconds * 1000},
}
if len(json.dumps(payload, allow_nan=False).encode("utf-8")) > MAX_REQUEST_BYTES:
raise ValueError("Serverless request exceeds the 10 MiB safety limit; reduce input media size")
result = await self._request("POST", "run", payload)
body = serialize_request(workflow, images, timeout_seconds)
result = await self._request("POST", "run", request_body=body)
try:
return checked_id(result.get("id"))
except ValueError:
+19 -5
View File
@@ -20,7 +20,7 @@ from PIL import Image, UnidentifiedImageError
MAX_INPUT_IMAGE_BYTES = 25 * 1024 * 1024
MAX_INPUT_TOTAL_BYTES = 7 * 1024 * 1024
MAX_INPUT_TOTAL_BYTES = 10_000_000
MAX_OUTPUT_FILES = 1024
MAX_OUTPUT_IMAGE_BYTES = 128 * 1024 * 1024
MAX_OUTPUT_TOTAL_BYTES = 256 * 1024 * 1024
@@ -183,13 +183,23 @@ def _read_limited(path, expected_size):
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_INPUT_IMAGE_BYTES:
raise ServerlessMediaError("Image input exceeds the permitted size")
data = handle.read(MAX_INPUT_IMAGE_BYTES + 1)
if info.st_size > MAX_INPUT_TOTAL_BYTES:
raise ServerlessMediaError(
f"Total raw serverless image input size exceeds {MAX_INPUT_TOTAL_BYTES:,} bytes; "
"base64 encoding adds about 33%, and the complete request body is also limited to 10,000,000 bytes"
)
data = handle.read(MAX_INPUT_TOTAL_BYTES + 1)
except ServerlessMediaError:
raise
except OSError:
raise ServerlessMediaError("Image input file could not be read safely") from None
if len(data) > MAX_INPUT_IMAGE_BYTES or len(data) != expected_size:
raise ServerlessMediaError("Image input exceeds the permitted size or changed while being read")
if len(data) > MAX_INPUT_TOTAL_BYTES:
raise ServerlessMediaError(
f"Total raw serverless image input size exceeds {MAX_INPUT_TOTAL_BYTES:,} bytes; "
"base64 encoding adds about 33%, and the complete request body is also limited to 10,000,000 bytes"
)
if len(data) != expected_size:
raise ServerlessMediaError("Image input changed while being read")
return data
@@ -229,7 +239,11 @@ async def prepare_images(workflow: dict) -> list[dict]:
raise ServerlessMediaError("Image input exceeds the permitted size")
total_source_bytes += size
if total_source_bytes > MAX_INPUT_TOTAL_BYTES:
raise ServerlessMediaError("Total serverless image inputs exceed the permitted size")
raise ServerlessMediaError(
f"Total raw serverless image input size is {total_source_bytes:,} bytes; "
f"the input read guard is {MAX_INPUT_TOTAL_BYTES:,} bytes. Base64 encoding "
"adds about 33%, and the complete request body is also limited to 10,000,000 bytes"
)
unique_paths[path] = {"size": size, "suffix": suffix}
resolved_refs.append((inputs, key, path))
+4
View File
@@ -165,12 +165,16 @@ async def _prepare_worker_payload(
if worker.get("type") == "serverless":
from .orchestration.serverless_prompt import compile_serverless_prompt
from .orchestration.serverless_media import prepare_images
from .orchestration.runpod_client import serialize_request
worker_prompt = apply_participant_overrides(
prune_prompt_for_worker(worker_prompt), worker["id"], enabled_ids,
job_id_map, "", delegate_master, prompt_index,
)
workflow, manifest = compile_serverless_prompt(worker_prompt)
images = await prepare_images(workflow)
# Fail during preparation, before regular workers or the master are
# dispatched. submit() uses this same serializer for the sent bytes.
serialize_request(workflow, images, worker["timeout_seconds"])
return worker, {"worker": worker, "workflow": workflow, "manifest": manifest, "images": images}
worker_master_url = build_master_callback_url(