Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/runloop_api_client/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@

INITIAL_RETRY_DELAY = 1.0
MAX_RETRY_DELAY = 60.0

# Maximum allowed size (in bytes) for individual entries in `file_mounts` when creating Blueprints
FILE_MOUNT_MAX_SIZE_BYTES = 512 * 1024
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep these limits static or adjust them based on Devbox resource allocation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This limit isn't a devbox limitation; it's a blueprint dockerfile length constraint. I think that each file_mount performs a text substitution inside the dockerfile, so the problem is that the dockerfile becomes too massive; not that the file_mounts exhaust resources for the devbox the dockerfile gets compiled on

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, makes sense!


# Maximum allowed total size (in bytes) across all `file_mounts` when creating Blueprints
FILE_MOUNT_TOTAL_MAX_SIZE_BYTES = 1024 * 1024
32 changes: 32 additions & 0 deletions src/runloop_api_client/resources/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
from .._constants import FILE_MOUNT_MAX_SIZE_BYTES, FILE_MOUNT_TOTAL_MAX_SIZE_BYTES
from ..pagination import SyncBlueprintsCursorIDPage, AsyncBlueprintsCursorIDPage
from .._exceptions import RunloopError
from ..lib.polling import PollingConfig, poll_until
Expand Down Expand Up @@ -50,6 +51,33 @@ class BlueprintRequestArgs(TypedDict, total=False):
__all__ = ["BlueprintsResource", "AsyncBlueprintsResource", "BlueprintRequestArgs"]


def _validate_file_mounts(file_mounts: Optional[Dict[str, str]] | Omit) -> None:
"""Validate file_mounts are within size constraints.

Currently enforces a maximum per-file size to avoid server-side issues with
large inline file contents. Also enforces a maximum total size across all
file_mounts.
"""

if file_mounts is omit or file_mounts is None:
return

total_size_bytes = 0
for mount_path, content in file_mounts.items():
# Measure size in bytes using UTF-8 encoding since payloads are JSON strings
size_bytes = len(content.encode("utf-8"))
if size_bytes > FILE_MOUNT_MAX_SIZE_BYTES:
raise ValueError(
Copy link
Copy Markdown
Contributor

@sid-rl sid-rl Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good idea to catch all large files instead of just the first one encountered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great feedback! Making this change now. Thank you

f"file_mount '{mount_path}' exceeds maximum size of {FILE_MOUNT_MAX_SIZE_BYTES} bytes. Use object_mounts instead."
)
total_size_bytes += size_bytes

if total_size_bytes > FILE_MOUNT_TOTAL_MAX_SIZE_BYTES:
raise ValueError(
f"total file_mounts size exceeds maximum of {FILE_MOUNT_TOTAL_MAX_SIZE_BYTES} bytes. Use object_mounts instead."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will we expect users to use file_mounts in combination with object_mounts? If so, it might be helpful to display what their total size is or how much they've exceeded the MAX_SIZE by, so they can make an informed partition of file_mounts vs object_mounts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the API needs to be flexible enough to support arbitrary combinations of object_mounts and file_mounts but the client-side should make it easy to do the right thing automatically (presumably by favoring object_mounts).

I think this change is worth making, but I wanted to provide more context as to why.

)


class BlueprintsResource(SyncAPIResource):
@cached_property
def with_raw_response(self) -> BlueprintsResourceWithRawResponse:
Expand Down Expand Up @@ -144,6 +172,8 @@ def create(

idempotency_key: Specify a custom idempotency key for this request
"""
_validate_file_mounts(file_mounts)

return self._post(
"/v1/blueprints",
body=maybe_transform(
Expand Down Expand Up @@ -758,6 +788,8 @@ async def create(

idempotency_key: Specify a custom idempotency key for this request
"""
_validate_file_mounts(file_mounts)

return await self._post(
"/v1/blueprints",
body=await async_maybe_transform(
Expand Down
54 changes: 54 additions & 0 deletions tests/api_resources/test_blueprints.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good idea to test that we don't reject file mounts when under or precisely at the limit

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just client-side validations. There's no point being hyper-precise about these limits since they're really enforced on the server side anyway -- there's nothing to stop a caller ignoring our client & using our API endpoints directly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,33 @@ def test_streaming_response_create(self, client: Runloop) -> None:

assert cast(Any, response.is_closed) is True

@parametrize
def test_create_rejects_large_file_mount(self, client: Runloop) -> None:
# 512KB + 1 byte
too_large_content = "a" * (512 * 1024 + 1)
with pytest.raises(ValueError, match=r"exceeds maximum size"):
client.blueprints.create(
name="name",
file_mounts={"/tmp/large.txt": too_large_content},
)

@parametrize
def test_create_rejects_total_file_mount_size(self, client: Runloop) -> None:
# Two files at exactly per-file max, plus 1 extra byte across a third file to exceed 1MB total
per_file_max = 512 * 1024
content_a = "a" * per_file_max
content_b = "b" * per_file_max
content_c = "c" * 1
with pytest.raises(ValueError, match=r"total file_mounts size exceeds maximum"):
client.blueprints.create(
name="name",
file_mounts={
"/tmp/a.txt": content_a,
"/tmp/b.txt": content_b,
"/tmp/c.txt": content_c,
},
)

@parametrize
def test_method_retrieve(self, client: Runloop) -> None:
blueprint = client.blueprints.retrieve(
Expand Down Expand Up @@ -536,6 +563,33 @@ async def test_streaming_response_create(self, async_client: AsyncRunloop) -> No

assert cast(Any, response.is_closed) is True

@parametrize
async def test_create_rejects_large_file_mount(self, async_client: AsyncRunloop) -> None:
# 512KB + 1 byte
too_large_content = "a" * (512 * 1024 + 1)
with pytest.raises(ValueError, match=r"exceeds maximum size"):
await async_client.blueprints.create(
name="name",
file_mounts={"/tmp/large.txt": too_large_content},
)

@parametrize
async def test_create_rejects_total_file_mount_size(self, async_client: AsyncRunloop) -> None:
# Two files at exactly per-file max, plus 1 extra byte across a third file to exceed 1MB total
per_file_max = 512 * 1024
content_a = "a" * per_file_max
content_b = "b" * per_file_max
content_c = "c" * 1
with pytest.raises(ValueError, match=r"total file_mounts size exceeds maximum"):
await async_client.blueprints.create(
name="name",
file_mounts={
"/tmp/a.txt": content_a,
"/tmp/b.txt": content_b,
"/tmp/c.txt": content_c,
},
)

@parametrize
async def test_method_retrieve(self, async_client: AsyncRunloop) -> None:
blueprint = await async_client.blueprints.retrieve(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.