From 4eb485f1934fcaa87515925fe28a607a4bb4674d Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Wed, 19 Aug 2026 15:56:28 -0400 Subject: [PATCH 1/3] Add multi-region sandbox support Expose ordered region preferences for sandbox creation, forks, and updates while keeping backend field mapping internal. Report sandbox and snapshot availability as immutable region tuples while sessions retain their actual landing region. --- .../vercel-sandbox/multi-region.feature.md | 2 ++ .../examples/sandbox_03_snapshot_restore.py | 7 ++++-- .../tests/test_sandbox_public_flow.py | 24 ++++++++++++++++++- src/vercel-sandbox/vercel/sandbox/__init__.py | 10 ++++++++ .../vercel/sandbox/_internal/api_client.py | 20 ++++++++++++++++ .../vercel/sandbox/_internal/async_runtime.py | 14 +++++++++++ .../vercel/sandbox/_internal/models.py | 15 ++++++++++++ .../sandbox/_internal/runtime_common.py | 8 +++++++ .../vercel/sandbox/_internal/service.py | 8 +++++++ .../vercel/sandbox/_internal/state.py | 2 ++ .../vercel/sandbox/_internal/sync_runtime.py | 10 ++++++++ src/vercel-sandbox/vercel/sandbox/sync.py | 10 ++++++++ 12 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 changes/vercel-sandbox/multi-region.feature.md diff --git a/changes/vercel-sandbox/multi-region.feature.md b/changes/vercel-sandbox/multi-region.feature.md new file mode 100644 index 00000000..2ea308bd --- /dev/null +++ b/changes/vercel-sandbox/multi-region.feature.md @@ -0,0 +1,2 @@ +Add ordered `regions` configuration for sandbox creation, forks, updates, and +snapshot availability. diff --git a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py index e6c12426..9d92c9fd 100644 --- a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py +++ b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py @@ -25,7 +25,10 @@ async def _main() -> None: restored = None snapshot = None - async with sandbox.create_sandbox(name=base_name) as base: + async with sandbox.create_sandbox( + name=base_name, + regions=("iad1", "sfo1"), + ) as base: try: await base.fs.write_text("state/message.txt", "restored from snapshot\n") snapshot = await base.snapshot() @@ -36,7 +39,7 @@ async def _main() -> None: ) content = await restored.fs.read_text("state/message.txt") assert content == "restored from snapshot\n" - print(f"{restored_name}: restored {snapshot.id}") + print(f"{restored_name}: restored {snapshot.id} from {snapshot.regions}") finally: if snapshot is not None: await snapshot.delete() diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index e6d9fc4d..6a9f261e 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -75,6 +75,8 @@ def _sandbox_response( "image": "vercel/sandbox/universal:latest", "status": status, "persistent": True, + "region": "iad1", + "failoverRegions": ["sfo1", "cle1"], "timeout": 300000, "snapshotExpiration": 0, "keepLastSnapshots": { @@ -91,6 +93,7 @@ def _sandbox_response( "projectId": project_id, "status": session_status or status, "cwd": "/vercel/sandbox", + "region": "cle1", "memory": 2048, "vcpus": 1, "timeout": 300000, @@ -155,6 +158,7 @@ def _snapshot_response( "id": snapshot_id, "sourceSessionId": session_id, "region": "iad1", + "regions": ["iad1", "sfo1", "cle1"], "status": status, "sizeBytes": 1024, "createdAt": 1, @@ -329,6 +333,8 @@ def handler(request: httpx.Request) -> httpx.Response: "deleteEvicted": False, }, "tags": {"env": "test"}, + "region": "iad1", + "failoverRegions": ["sfo1", "cle1"], } response = _sandbox_response(project_id="prj_other") payload = response["sandbox"] @@ -344,6 +350,8 @@ def handler(request: httpx.Request) -> httpx.Response: "name": "preview", "currentSessionId": "sbx_123", "tags": {"env": "updated"}, + "region": "sfo1", + "failoverRegions": [], } }, { @@ -390,8 +398,11 @@ def update_handler(request: httpx.Request) -> httpx.Response: delete_evicted=False, ), tags={"env": "test"}, + regions=("iad1", "sfo1", "cle1"), ) assert handle.image == "vercel/sandbox/universal:latest" + assert handle.region == "iad1" + assert handle.regions == ("iad1", "sfo1", "cle1") assert not hasattr(handle, "runtime") assert handle.current_session is not None assert not hasattr(handle.current_session, "runtime") @@ -407,11 +418,15 @@ def update_handler(request: httpx.Request) -> httpx.Response: execution_time_limit=4.5, snapshot_expiration=0, snapshot_retention=SnapshotRetention(count=1, expiration=0), + regions=("sfo1",), ) assert handle.tags == {"env": "updated"} + assert handle.region == "sfo1" + assert handle.regions == ("sfo1",) + assert handle.current_session is retained_session + assert handle.current_session.region == "cle1" assert handle.routes[0].url == "https://preview.sandbox.test" assert handle.project_id == "prj_other" - assert handle.current_session is retained_session await handle.update(tags={}, ports=[]) assert handle.tags == {} @@ -438,6 +453,8 @@ def update_handler(request: httpx.Request) -> httpx.Response: "deleteEvicted": True, }, "tags": {"env": "updated"}, + "region": "sfo1", + "failoverRegions": [], }, {"ports": [], "tags": {}}, {"keepLastSnapshots": None}, @@ -491,6 +508,7 @@ async def test_public_fork_sandbox_encodes_overrides_polls_and_cleans_up( env={}, tags={}, snapshot_expiration=0, + regions=("iad1", "sfo1"), snapshot_retention=SnapshotRetention( count=2, expiration=timedelta(days=1), @@ -505,6 +523,8 @@ async def test_public_fork_sandbox_encodes_overrides_polls_and_cleans_up( assert json.loads(request.content) == { "name": "forked", "ports": [], + "region": "iad1", + "failoverRegions": ["sfo1"], "timeout": 12500, "resources": {"vcpus": 2, "memory": 4096}, "image": "team/project/image:v1", @@ -1826,6 +1846,8 @@ async def test_closed_session_rejects_handles_and_lazy_readers(mock_env_clear: N runtime_session = resumed.current_session command = await handle.create_process("sleep", ["30"]) snapshot = await handle.snapshot() + assert snapshot.region == "iad1" + assert snapshot.regions == ("iad1", "sfo1", "cle1") with pytest.raises(VercelSessionClosedError): await handle.create_process("true") diff --git a/src/vercel-sandbox/vercel/sandbox/__init__.py b/src/vercel-sandbox/vercel/sandbox/__init__.py index 6fcdb8ea..49623180 100644 --- a/src/vercel-sandbox/vercel/sandbox/__init__.py +++ b/src/vercel-sandbox/vercel/sandbox/__init__.py @@ -60,6 +60,7 @@ NetworkPolicySubnets, NetworkPolicyTransform, ProcessStatus, + RegionsInput, SandboxQuery, SandboxQueryByCreatedAt, SandboxQueryByCurrentSnapshotId, @@ -106,6 +107,7 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: """Prepare an asynchronous sandbox creation operation. @@ -132,6 +134,7 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + regions: Ordered preferred and failover regions. destroy: Whether context-manager exit destroys the sandbox after stopping it. Awaiting the operation never triggers cleanup. @@ -157,6 +160,7 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, destroy=destroy, ) @@ -176,6 +180,7 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: """Prepare an asynchronous sandbox fork operation. @@ -204,6 +209,7 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. + regions: Ordered preferred and failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. Awaiting the operation never triggers cleanup. @@ -228,6 +234,7 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, destroy=destroy, ) @@ -249,6 +256,7 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, ) -> tuple[Sandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -277,6 +285,7 @@ async def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + regions: Ordered preferred and failover regions. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -300,6 +309,7 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, ) diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py index f49f314d..cf8df305 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py @@ -145,6 +145,8 @@ class _SandboxCreationOverridesRequest(_ApiRequestModel): keep_last_snapshots: SnapshotRetention | None = Field( default=None, serialization_alias="keepLastSnapshots" ) + region: str | None = None + failover_regions: list[str] | None = Field(default=None, serialization_alias="failoverRegions") @field_serializer("timeout") def _serialize_duration(self, value: timedelta | None) -> int | None: @@ -192,6 +194,8 @@ class _UpdateSandboxRequest(_ApiRequestModel): default=None, serialization_alias="snapshotExpiration" ) current_snapshot_id: str | None = Field(default=None, serialization_alias="currentSnapshotId") + region: str | None = None + failover_regions: list[str] | None = Field(default=None, serialization_alias="failoverRegions") @field_serializer("timeout") def _serialize_duration(self, value: timedelta | None) -> int | None: @@ -393,6 +397,10 @@ class _SandboxPayload(_ApiModel): ) cwd: str | None = None region: str | None = None + failover_regions: tuple[str, ...] | None = Field( + default=None, + validation_alias=AliasChoices("failover_regions", "failoverRegions"), + ) memory: int | None = None vcpus: int | None = None execution_time_limit: int | None = Field( @@ -443,6 +451,7 @@ class _SnapshotPayload(_ApiModel): serialization_alias="sourceSessionId", ) region: str + regions: tuple[str, ...] | None = None status: Literal["created", "deleted", "failed"] size_bytes: int = Field( validation_alias=AliasChoices("size_bytes", "sizeBytes"), serialization_alias="sizeBytes" @@ -693,6 +702,7 @@ def _sandbox_state( project_id=payload.project_id or project_id, cwd=payload.cwd, region=payload.region, + regions=(payload.region or "iad1", *(payload.failover_regions or ())), memory=payload.memory, vcpus=payload.vcpus, execution_time_limit=parse_duration(payload.execution_time_limit, MILLISECOND), @@ -734,6 +744,7 @@ def _snapshot_state(payload: _SnapshotPayload) -> SnapshotState: id=payload.id, source_session_id=payload.source_session_id, region=payload.region, + regions=payload.regions or (payload.region,), status=payload.status, size_bytes=payload.size_bytes, expires_at=payload.expires_at, @@ -922,6 +933,7 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _CreateSandboxRequest( @@ -938,6 +950,8 @@ async def create_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, + region=None if regions is None else regions[0], + failover_regions=None if regions is None else list(regions[1:]), ) data = await self._request_json( "POST", "v3/sandboxes", credentials=credentials, body=request.to_api_dict() @@ -960,6 +974,7 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _ForkSandboxRequest( @@ -974,6 +989,8 @@ async def fork_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, + region=None if regions is None else regions[0], + failover_regions=None if regions is None else list(regions[1:]), ) data = await self._request_json( "POST", @@ -1073,6 +1090,7 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() effective_project_id = project_id or credentials.project_id @@ -1086,6 +1104,8 @@ async def update_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, current_snapshot_id=current_snapshot_id, + region=None if regions is None else regions[0], + failover_regions=None if regions is None else list(regions[1:]), ) body = request.to_api_dict() if not isinstance(snapshot_retention, _Omitted): diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py index 4cac592b..01529508 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py @@ -44,6 +44,7 @@ DurationInput, NetworkPolicy, ProcessLog, + RegionsInput, SandboxQuery, SandboxResources, SandboxSource, @@ -54,6 +55,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, + normalize_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1286,6 +1288,7 @@ async def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + regions: RegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1312,6 +1315,7 @@ async def update( snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, + regions=normalize_regions(regions), ) self._apply_payload(payload) return self @@ -1419,6 +1423,7 @@ class _CreateSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None + regions: tuple[str, ...] | None = None class CreateSandboxOperation: @@ -1465,6 +1470,7 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, + regions=self._params.regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1511,6 +1517,7 @@ class _ForkSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None + regions: tuple[str, ...] | None = None class ForkSandboxOperation: @@ -1556,6 +1563,7 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, + regions=self._params.regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1705,6 +1713,7 @@ def create_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: return CreateSandboxOperation( @@ -1723,6 +1732,7 @@ def create_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ), destroy=destroy, ) @@ -1744,6 +1754,7 @@ def fork_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: return ForkSandboxOperation( @@ -1762,6 +1773,7 @@ def fork_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ), destroy=destroy, ) @@ -1805,6 +1817,7 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, ) -> tuple[Sandbox, bool]: try: state, created = await service.get_or_create_sandbox( @@ -1823,6 +1836,7 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ) return ( Sandbox( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/models.py b/src/vercel-sandbox/vercel/sandbox/_internal/models.py index 6f4c8a2f..eae5d1ef 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/models.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/models.py @@ -21,11 +21,26 @@ JSONValue: TypeAlias = PydanticJsonValue JSONObject: TypeAlias = dict[str, JSONValue] DurationInput: TypeAlias = int | float | timedelta | None +RegionsInput: TypeAlias = Iterable[str] | None _MIN_SNAPSHOT_EXPIRATION = timedelta(days=1) _MAX_SNAPSHOT_EXPIRATION = timedelta(days=365 * 10) _ZERO_DELTA = timedelta(0) +def normalize_regions(regions: RegionsInput) -> tuple[str, ...] | None: + """Normalize an ordered sandbox region preference.""" + if regions is None: + return None + normalized = tuple(regions) + if not normalized: + raise ValueError("regions must include at least one region") + if any(not isinstance(region, str) or not region for region in normalized): + raise ValueError("regions must contain non-empty strings") + if len(set(normalized)) != len(normalized): + raise ValueError("regions must not contain duplicates") + return normalized + + @dataclass(frozen=True, slots=True) class NetworkPolicyMatcher: """Match one request value using one comparison strategy.""" diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py index 1d9645ce..1e3d5b4c 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py @@ -258,6 +258,10 @@ def id(self) -> str: def source_session_id(self) -> str: return self._payload.source_session_id + @property + def regions(self) -> tuple[str, ...]: + return self._payload.regions + @property def region(self) -> str: return self._payload.region @@ -458,6 +462,10 @@ def project_id(self) -> str | None: def cwd(self) -> str | None: return self._payload.cwd + @property + def regions(self) -> tuple[str, ...]: + return self._payload.regions + @property def region(self) -> str | None: return self._payload.region diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/service.py b/src/vercel-sandbox/vercel/sandbox/_internal/service.py index 1dbd0ff0..9bc0e238 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/service.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/service.py @@ -264,6 +264,7 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.create_sandbox( @@ -280,6 +281,7 @@ async def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -299,6 +301,7 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.fork_sandbox( @@ -315,6 +318,7 @@ async def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -352,6 +356,7 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + regions: tuple[str, ...] | None = None, ) -> tuple[SandboxState, bool]: """Return a named sandbox and whether it had to be created.""" try: @@ -389,6 +394,7 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, ) return sandbox, True @@ -431,6 +437,7 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() return await self._api_client.update_sandbox( @@ -446,6 +453,7 @@ async def update_sandbox( snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, + regions=regions, ) async def resume_sandbox( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/state.py b/src/vercel-sandbox/vercel/sandbox/_internal/state.py index 1e1b2d60..f4245deb 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/state.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/state.py @@ -70,6 +70,7 @@ class SandboxState: project_id: str | None = None cwd: str | None = None region: str | None = None + regions: tuple[str, ...] = ("iad1",) memory: int | None = None vcpus: int | None = None execution_time_limit: timedelta | None = None @@ -101,6 +102,7 @@ class SnapshotState: id: str source_session_id: str region: str + regions: tuple[str, ...] status: Literal["created", "deleted", "failed"] size_bytes: int expires_at: int | None = None diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py index 21bd58a9..19c06d90 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py @@ -38,6 +38,7 @@ DurationInput, NetworkPolicy, ProcessLog, + RegionsInput, SandboxQuery, SandboxResources, SandboxSource, @@ -47,6 +48,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, + normalize_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1405,6 +1407,7 @@ def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + regions: RegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1431,6 +1434,7 @@ def update( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), current_snapshot_id=current_snapshot_id, ) ) @@ -1507,6 +1511,7 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1525,6 +1530,7 @@ def create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ) ) return _ManagedSyncSandbox( @@ -1552,6 +1558,7 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1570,6 +1577,7 @@ def fork_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ) ) return _ManagedSyncSandbox( @@ -1621,6 +1629,7 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, ) -> tuple[SyncSandbox, bool]: try: state, created = iter_coroutine( @@ -1640,6 +1649,7 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + regions=normalize_regions(regions), ) ) return ( diff --git a/src/vercel-sandbox/vercel/sandbox/sync.py b/src/vercel-sandbox/vercel/sandbox/sync.py index 4c6858e4..9fe94d00 100644 --- a/src/vercel-sandbox/vercel/sandbox/sync.py +++ b/src/vercel-sandbox/vercel/sandbox/sync.py @@ -33,6 +33,7 @@ NetworkPolicySubnets, NetworkPolicyTransform, ProcessStatus, + RegionsInput, SandboxQuery, SandboxQueryByCreatedAt, SandboxQueryByCurrentSnapshotId, @@ -101,6 +102,7 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Create a sandbox and wait until it is ready. @@ -127,6 +129,7 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + regions: Ordered preferred and failover regions. destroy: Whether context-manager exit destroys the sandbox after stopping it. @@ -151,6 +154,7 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, destroy=destroy, ) @@ -170,6 +174,7 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Fork a sandbox and wait until the fork is ready. @@ -199,6 +204,7 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. + regions: Ordered preferred and failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. @@ -223,6 +229,7 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, destroy=destroy, ) @@ -244,6 +251,7 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + regions: RegionsInput = None, ) -> tuple[SyncSandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -272,6 +280,7 @@ def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + regions: Ordered preferred and failover regions. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -295,6 +304,7 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + regions=regions, ) From dad49cf1c07d2d24fe91a887387b6dd0f798f7ba Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Wed, 19 Aug 2026 21:10:58 -0400 Subject: [PATCH 2/3] Decompose regions into region+failover_regions I was wrong in my assumption that failover_regions was an ordered preference. Failover actually works by selecting the next closest region, so instead of having to explain that explicitly, let's just do the obvious thing here. --- .../vercel-sandbox/multi-region.feature.md | 4 +- .../examples/sandbox_03_snapshot_restore.py | 3 +- .../tests/test_sandbox_public_flow.py | 13 +++--- src/vercel-sandbox/vercel/sandbox/__init__.py | 29 +++++++++----- .../vercel/sandbox/_internal/api_client.py | 23 ++++++----- .../vercel/sandbox/_internal/async_runtime.py | 40 ++++++++++++------- .../vercel/sandbox/_internal/models.py | 12 +++--- .../sandbox/_internal/runtime_common.py | 8 ++-- .../vercel/sandbox/_internal/service.py | 24 +++++++---- .../vercel/sandbox/_internal/state.py | 2 +- .../vercel/sandbox/_internal/sync_runtime.py | 28 ++++++++----- src/vercel-sandbox/vercel/sandbox/sync.py | 29 +++++++++----- 12 files changed, 133 insertions(+), 82 deletions(-) diff --git a/changes/vercel-sandbox/multi-region.feature.md b/changes/vercel-sandbox/multi-region.feature.md index 2ea308bd..72f7d01a 100644 --- a/changes/vercel-sandbox/multi-region.feature.md +++ b/changes/vercel-sandbox/multi-region.feature.md @@ -1,2 +1,2 @@ -Add ordered `regions` configuration for sandbox creation, forks, updates, and -snapshot availability. +Add `region` and `failover_regions` configuration for sandbox creation, forks, +and updates, plus multi-region snapshot availability reporting. diff --git a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py index 9d92c9fd..423846a4 100644 --- a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py +++ b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py @@ -27,7 +27,8 @@ async def _main() -> None: async with sandbox.create_sandbox( name=base_name, - regions=("iad1", "sfo1"), + region="iad1", + failover_regions=("sfo1",), ) as base: try: await base.fs.write_text("state/message.txt", "restored from snapshot\n") diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index 6a9f261e..07206f6c 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -398,11 +398,12 @@ def update_handler(request: httpx.Request) -> httpx.Response: delete_evicted=False, ), tags={"env": "test"}, - regions=("iad1", "sfo1", "cle1"), + region="iad1", + failover_regions=("sfo1", "cle1"), ) assert handle.image == "vercel/sandbox/universal:latest" assert handle.region == "iad1" - assert handle.regions == ("iad1", "sfo1", "cle1") + assert handle.failover_regions == ("sfo1", "cle1") assert not hasattr(handle, "runtime") assert handle.current_session is not None assert not hasattr(handle.current_session, "runtime") @@ -418,11 +419,12 @@ def update_handler(request: httpx.Request) -> httpx.Response: execution_time_limit=4.5, snapshot_expiration=0, snapshot_retention=SnapshotRetention(count=1, expiration=0), - regions=("sfo1",), + region="sfo1", + failover_regions=(), ) assert handle.tags == {"env": "updated"} assert handle.region == "sfo1" - assert handle.regions == ("sfo1",) + assert handle.failover_regions == () assert handle.current_session is retained_session assert handle.current_session.region == "cle1" assert handle.routes[0].url == "https://preview.sandbox.test" @@ -508,7 +510,8 @@ async def test_public_fork_sandbox_encodes_overrides_polls_and_cleans_up( env={}, tags={}, snapshot_expiration=0, - regions=("iad1", "sfo1"), + region="iad1", + failover_regions=("sfo1",), snapshot_retention=SnapshotRetention( count=2, expiration=timedelta(days=1), diff --git a/src/vercel-sandbox/vercel/sandbox/__init__.py b/src/vercel-sandbox/vercel/sandbox/__init__.py index 49623180..52b1f325 100644 --- a/src/vercel-sandbox/vercel/sandbox/__init__.py +++ b/src/vercel-sandbox/vercel/sandbox/__init__.py @@ -51,6 +51,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, GitSource, NetworkPolicy, NetworkPolicyKeyValueMatcher, @@ -60,7 +61,6 @@ NetworkPolicySubnets, NetworkPolicyTransform, ProcessStatus, - RegionsInput, SandboxQuery, SandboxQueryByCreatedAt, SandboxQueryByCurrentSnapshotId, @@ -107,7 +107,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: """Prepare an asynchronous sandbox creation operation. @@ -134,7 +135,8 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. - regions: Ordered preferred and failover regions. + region: Preferred region for the sandbox. + failover_regions: Regions available if creation in ``region`` fails. destroy: Whether context-manager exit destroys the sandbox after stopping it. Awaiting the operation never triggers cleanup. @@ -160,7 +162,8 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -180,7 +183,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: """Prepare an asynchronous sandbox fork operation. @@ -209,7 +213,8 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. - regions: Ordered preferred and failover region override. + region: Preferred region override. + failover_regions: Failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. Awaiting the operation never triggers cleanup. @@ -234,7 +239,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -256,7 +262,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[Sandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -285,7 +292,8 @@ async def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. - regions: Ordered preferred and failover regions. + region: Preferred region for a newly created sandbox. + failover_regions: Failover regions for a newly created sandbox. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -309,7 +317,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, ) diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py index cf8df305..e7f51785 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py @@ -702,7 +702,7 @@ def _sandbox_state( project_id=payload.project_id or project_id, cwd=payload.cwd, region=payload.region, - regions=(payload.region or "iad1", *(payload.failover_regions or ())), + failover_regions=payload.failover_regions or (), memory=payload.memory, vcpus=payload.vcpus, execution_time_limit=parse_duration(payload.execution_time_limit, MILLISECOND), @@ -933,7 +933,8 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _CreateSandboxRequest( @@ -950,8 +951,8 @@ async def create_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, - region=None if regions is None else regions[0], - failover_regions=None if regions is None else list(regions[1:]), + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) data = await self._request_json( "POST", "v3/sandboxes", credentials=credentials, body=request.to_api_dict() @@ -974,7 +975,8 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _ForkSandboxRequest( @@ -989,8 +991,8 @@ async def fork_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, - region=None if regions is None else regions[0], - failover_regions=None if regions is None else list(regions[1:]), + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) data = await self._request_json( "POST", @@ -1090,7 +1092,8 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() effective_project_id = project_id or credentials.project_id @@ -1104,8 +1107,8 @@ async def update_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, current_snapshot_id=current_snapshot_id, - region=None if regions is None else regions[0], - failover_regions=None if regions is None else list(regions[1:]), + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) body = request.to_api_dict() if not isinstance(snapshot_retention, _Omitted): diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py index 01529508..6adcaae9 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py @@ -42,9 +42,9 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, NetworkPolicy, ProcessLog, - RegionsInput, SandboxQuery, SandboxResources, SandboxSource, @@ -55,7 +55,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, - normalize_regions, + normalize_failover_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1288,7 +1288,8 @@ async def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1315,7 +1316,8 @@ async def update( snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) self._apply_payload(payload) return self @@ -1423,7 +1425,8 @@ class _CreateSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None - regions: tuple[str, ...] | None = None + region: str | None = None + failover_regions: tuple[str, ...] | None = None class CreateSandboxOperation: @@ -1470,7 +1473,8 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, - regions=self._params.regions, + region=self._params.region, + failover_regions=self._params.failover_regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1517,7 +1521,8 @@ class _ForkSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None - regions: tuple[str, ...] | None = None + region: str | None = None + failover_regions: tuple[str, ...] | None = None class ForkSandboxOperation: @@ -1563,7 +1568,8 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, - regions=self._params.regions, + region=self._params.region, + failover_regions=self._params.failover_regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1713,7 +1719,8 @@ def create_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: return CreateSandboxOperation( @@ -1732,7 +1739,8 @@ def create_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ), destroy=destroy, ) @@ -1754,7 +1762,8 @@ def fork_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: return ForkSandboxOperation( @@ -1773,7 +1782,8 @@ def fork_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ), destroy=destroy, ) @@ -1817,7 +1827,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[Sandbox, bool]: try: state, created = await service.get_or_create_sandbox( @@ -1836,7 +1847,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) return ( Sandbox( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/models.py b/src/vercel-sandbox/vercel/sandbox/_internal/models.py index eae5d1ef..f7cd9b0a 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/models.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/models.py @@ -21,23 +21,21 @@ JSONValue: TypeAlias = PydanticJsonValue JSONObject: TypeAlias = dict[str, JSONValue] DurationInput: TypeAlias = int | float | timedelta | None -RegionsInput: TypeAlias = Iterable[str] | None +FailoverRegionsInput: TypeAlias = Iterable[str] | None _MIN_SNAPSHOT_EXPIRATION = timedelta(days=1) _MAX_SNAPSHOT_EXPIRATION = timedelta(days=365 * 10) _ZERO_DELTA = timedelta(0) -def normalize_regions(regions: RegionsInput) -> tuple[str, ...] | None: - """Normalize an ordered sandbox region preference.""" +def normalize_failover_regions(regions: FailoverRegionsInput) -> tuple[str, ...] | None: + """Normalize sandbox failover regions while preserving explicit emptiness.""" if regions is None: return None normalized = tuple(regions) - if not normalized: - raise ValueError("regions must include at least one region") if any(not isinstance(region, str) or not region for region in normalized): - raise ValueError("regions must contain non-empty strings") + raise ValueError("failover_regions must contain non-empty strings") if len(set(normalized)) != len(normalized): - raise ValueError("regions must not contain duplicates") + raise ValueError("failover_regions must not contain duplicates") return normalized diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py index 1e3d5b4c..6c5fc3df 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py @@ -462,14 +462,14 @@ def project_id(self) -> str | None: def cwd(self) -> str | None: return self._payload.cwd - @property - def regions(self) -> tuple[str, ...]: - return self._payload.regions - @property def region(self) -> str | None: return self._payload.region + @property + def failover_regions(self) -> tuple[str, ...]: + return self._payload.failover_regions + @property def memory(self) -> int | None: return self._payload.memory diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/service.py b/src/vercel-sandbox/vercel/sandbox/_internal/service.py index 9bc0e238..8a336ff0 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/service.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/service.py @@ -264,7 +264,8 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.create_sandbox( @@ -281,7 +282,8 @@ async def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -301,7 +303,8 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.fork_sandbox( @@ -318,7 +321,8 @@ async def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -356,7 +360,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> tuple[SandboxState, bool]: """Return a named sandbox and whether it had to be created.""" try: @@ -394,7 +399,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, ) return sandbox, True @@ -437,7 +443,8 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, - regions: tuple[str, ...] | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() return await self._api_client.update_sandbox( @@ -453,7 +460,8 @@ async def update_sandbox( snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, - regions=regions, + region=region, + failover_regions=failover_regions, ) async def resume_sandbox( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/state.py b/src/vercel-sandbox/vercel/sandbox/_internal/state.py index f4245deb..9ea3c1ad 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/state.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/state.py @@ -70,7 +70,7 @@ class SandboxState: project_id: str | None = None cwd: str | None = None region: str | None = None - regions: tuple[str, ...] = ("iad1",) + failover_regions: tuple[str, ...] = () memory: int | None = None vcpus: int | None = None execution_time_limit: timedelta | None = None diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py index 19c06d90..e4653e4f 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py @@ -36,9 +36,9 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, NetworkPolicy, ProcessLog, - RegionsInput, SandboxQuery, SandboxResources, SandboxSource, @@ -48,7 +48,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, - normalize_regions, + normalize_failover_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1407,7 +1407,8 @@ def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1434,7 +1435,8 @@ def update( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), current_snapshot_id=current_snapshot_id, ) ) @@ -1511,7 +1513,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1530,7 +1533,8 @@ def create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return _ManagedSyncSandbox( @@ -1558,7 +1562,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1577,7 +1582,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return _ManagedSyncSandbox( @@ -1629,7 +1635,8 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[SyncSandbox, bool]: try: state, created = iter_coroutine( @@ -1649,7 +1656,8 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, - regions=normalize_regions(regions), + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return ( diff --git a/src/vercel-sandbox/vercel/sandbox/sync.py b/src/vercel-sandbox/vercel/sandbox/sync.py index 9fe94d00..8a364f72 100644 --- a/src/vercel-sandbox/vercel/sandbox/sync.py +++ b/src/vercel-sandbox/vercel/sandbox/sync.py @@ -24,6 +24,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, GitSource, NetworkPolicy, NetworkPolicyKeyValueMatcher, @@ -33,7 +34,6 @@ NetworkPolicySubnets, NetworkPolicyTransform, ProcessStatus, - RegionsInput, SandboxQuery, SandboxQueryByCreatedAt, SandboxQueryByCurrentSnapshotId, @@ -102,7 +102,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Create a sandbox and wait until it is ready. @@ -129,7 +130,8 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. - regions: Ordered preferred and failover regions. + region: Preferred region for the sandbox. + failover_regions: Regions available if creation in ``region`` fails. destroy: Whether context-manager exit destroys the sandbox after stopping it. @@ -154,7 +156,8 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -174,7 +177,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Fork a sandbox and wait until the fork is ready. @@ -204,7 +208,8 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. - regions: Ordered preferred and failover region override. + region: Preferred region override. + failover_regions: Failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. @@ -229,7 +234,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -251,7 +257,8 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, - regions: RegionsInput = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[SyncSandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -280,7 +287,8 @@ def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. - regions: Ordered preferred and failover regions. + region: Preferred region for a newly created sandbox. + failover_regions: Failover regions for a newly created sandbox. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -304,7 +312,8 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - regions=regions, + region=region, + failover_regions=failover_regions, ) From 08a5a4cd4f68c08cf380cb131cd34a87169ca9f8 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 20 Aug 2026 13:09:41 -0400 Subject: [PATCH 3/3] Move `region` default into sandbox options --- src/vercel-sandbox/tests/conftest.py | 1 + src/vercel-sandbox/tests/sandbox_fixtures.py | 3 ++ src/vercel-sandbox/tests/test_options.py | 17 ++++++ .../tests/test_sandbox_public_flow.py | 52 +++++++++++++++++++ .../vercel/sandbox/_internal/options.py | 7 +++ .../vercel/sandbox/_internal/service.py | 7 +-- 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/vercel-sandbox/tests/conftest.py b/src/vercel-sandbox/tests/conftest.py index ff2a7808..edfd36d6 100644 --- a/src/vercel-sandbox/tests/conftest.py +++ b/src/vercel-sandbox/tests/conftest.py @@ -14,6 +14,7 @@ def mock_env_clear(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, Non "VERCEL_PROJECT_ID", "VERCEL_OIDC_TOKEN", "VERCEL_OIDC_TOKEN_HEADER", + "VERCEL_REGION", ): monkeypatch.delenv(name, raising=False) diff --git a/src/vercel-sandbox/tests/sandbox_fixtures.py b/src/vercel-sandbox/tests/sandbox_fixtures.py index e3120b86..ae2cc757 100644 --- a/src/vercel-sandbox/tests/sandbox_fixtures.py +++ b/src/vercel-sandbox/tests/sandbox_fixtures.py @@ -12,6 +12,7 @@ def sandbox_service_options( token: str = "token", team_id: str = "team_123", project_id: str = "prj_123", + region: str | None = None, sync: bool | None = None, ) -> list[ServiceOptions]: """Build Sandbox options for the test's current session mode.""" @@ -34,6 +35,7 @@ def sync_credentials_factory() -> SandboxCredentials: sandbox_sync.SandboxServiceOptions( base_url=base_url, credentials_factory=sync_credentials_factory, + region=region, ) ] @@ -44,5 +46,6 @@ async def async_credentials_factory() -> SandboxCredentials: SandboxServiceOptions( base_url=base_url, credentials_factory=async_credentials_factory, + region=region, ) ] diff --git a/src/vercel-sandbox/tests/test_options.py b/src/vercel-sandbox/tests/test_options.py index 5f12ce2b..5470258f 100644 --- a/src/vercel-sandbox/tests/test_options.py +++ b/src/vercel-sandbox/tests/test_options.py @@ -12,6 +12,23 @@ ) +def test_options_resolves_region_from_argument_or_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VERCEL_REGION", raising=False) + assert SandboxServiceOptions().region is None + + monkeypatch.setenv("VERCEL_REGION", "iad1") + assert SandboxServiceOptions().region == "iad1" + assert SandboxServiceOptions(region="sfo1").region == "sfo1" + assert sandbox.sync.SandboxServiceOptions().region == "iad1" + assert sandbox.sync.SandboxServiceOptions(region="cle1").region == "cle1" + + monkeypatch.setenv("VERCEL_REGION", "") + assert SandboxServiceOptions().region is None + assert sandbox.sync.SandboxServiceOptions().region is None + + def test_options_equality_preserves_custom_credential_factory_identity() -> None: assert SandboxServiceOptions() == SandboxServiceOptions() assert SandboxServiceOptions( diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index 07206f6c..4d62b3eb 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -568,6 +568,58 @@ def test_sync_fork_sandbox_uses_inherited_defaults(mock_env_clear: None) -> None assert json.loads(request.content) == {} +@respx.mock +async def test_service_region_defaults_placement_operations_and_allows_call_overrides( + mock_env_clear: None, +) -> None: + create_route = respx.post("https://sandbox.test/v3/sandboxes").mock( + return_value=httpx.Response(200, json=_sandbox_response(name="created")) + ) + fork_route = respx.post("https://sandbox.test/v2/sandboxes/source/fork").mock( + return_value=httpx.Response(200, json=_sandbox_response(name="forked")) + ) + update_route = respx.patch("https://sandbox.test/v2/sandboxes/created").mock( + return_value=httpx.Response( + 200, + json={"sandbox": {"name": "created", "currentSessionId": "sbx_123"}}, + ) + ) + + async with session(service_options=_session_options(region="iad1")): + created = await sandbox.create_sandbox(name="created") + await sandbox.fork_sandbox(source_sandbox="source", region="sfo1") + await created.update(tags={"updated": "true"}) + + assert json.loads(create_route.calls.last.request.content) == { + "projectId": "prj_123", + "name": "created", + "region": "iad1", + } + assert json.loads(fork_route.calls.last.request.content) == {"region": "sfo1"} + assert json.loads(update_route.calls.last.request.content) == { + "tags": {"updated": "true"}, + "region": "iad1", + } + + +@respx.mock +def test_sync_service_region_defaults_fork_from_environment( + mock_env_clear: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VERCEL_REGION", "cle1") + fork_route = respx.post("https://sandbox.test/v2/sandboxes/source/fork").mock( + return_value=httpx.Response( + 200, json=_sandbox_response(name="forked", session_id="sbx_fork") + ) + ) + + with session(service_options=_session_options(sync=True)): + sandbox_sync.fork_sandbox(source_sandbox="source") + + assert json.loads(fork_route.calls.last.request.content) == {"region": "cle1"} + + @respx.mock async def test_network_policy_async_public_flow(mock_env_clear: None) -> None: create_route = respx.post("https://sandbox.test/v3/sandboxes").mock( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/options.py b/src/vercel-sandbox/vercel/sandbox/_internal/options.py index cf88f73c..4176d77c 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/options.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/options.py @@ -5,6 +5,7 @@ from typing import Protocol from vercel._internal.core.options import ServiceOptions +from vercel.env import get_env from vercel.sandbox._internal.errors import SandboxCredentialsError DEFAULT_SANDBOX_API_BASE_URL = "https://vercel.com/api" @@ -67,6 +68,7 @@ class SandboxServiceOptions(_SandboxServiceOptionsKey): base_url: str credentials_factory: SandboxCredentialsFactory file_transfer_timeout: timedelta + region: str | None def __init__( self, @@ -74,6 +76,7 @@ def __init__( base_url: str | None = None, credentials_factory: SandboxCredentialsFactory | None = None, file_transfer_timeout: timedelta | None = None, + region: str | None = None, ) -> None: object.__setattr__( self, @@ -92,6 +95,7 @@ def __init__( if file_transfer_timeout is not None else _DEFAULT_FILE_TRANSFER_TIMEOUT, ) + object.__setattr__(self, "region", region or get_env().VERCEL_REGION) @dataclass(frozen=True, slots=True, init=False) @@ -101,6 +105,7 @@ class SyncSandboxServiceOptions(_SandboxServiceOptionsKey): base_url: str credentials_factory: SyncSandboxCredentialsFactory file_transfer_timeout: timedelta + region: str | None def __init__( self, @@ -108,6 +113,7 @@ def __init__( base_url: str | None = None, credentials_factory: SyncSandboxCredentialsFactory | None = None, file_transfer_timeout: timedelta | None = None, + region: str | None = None, ) -> None: object.__setattr__( self, @@ -126,3 +132,4 @@ def __init__( if file_transfer_timeout is not None else _DEFAULT_FILE_TRANSFER_TIMEOUT, ) + object.__setattr__(self, "region", region or get_env().VERCEL_REGION) diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/service.py b/src/vercel-sandbox/vercel/sandbox/_internal/service.py index 8a336ff0..6404eeac 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/service.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/service.py @@ -282,7 +282,7 @@ async def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - region=region, + region=region or self._options.region, failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -321,7 +321,7 @@ async def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, - region=region, + region=region or self._options.region, failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -460,7 +460,7 @@ async def update_sandbox( snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, - region=region, + region=region or self._options.region, failover_regions=failover_regions, ) @@ -1126,6 +1126,7 @@ def factory() -> SandboxService: base_url=sync_options.base_url, credentials_factory=_adapt_sync_credentials_factory(sync_options.credentials_factory), file_transfer_timeout=sync_options.file_transfer_timeout, + region=sync_options.region, ) return SandboxService( api_client=SandboxApiClient(