Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.24.0"
".": "1.25.0"
}
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## 1.25.0 (2026-07-24)

Full Changelog: [v1.24.0...v1.25.0](https://github.com/runloopai/api-client-python/compare/v1.24.0...v1.25.0)

### Features

* **mux:** wait_for_eviction endpoint for flex eviction notifications ([#10325](https://github.com/runloopai/api-client-python/issues/10325)) ([c06794d](https://github.com/runloopai/api-client-python/commit/c06794d413f37be16ee5955fe31b07b92b8adbc8))


### Bug Fixes

* **mux:** field-level merge on network policy egress updates ([#10338](https://github.com/runloopai/api-client-python/issues/10338)) ([cfc97ef](https://github.com/runloopai/api-client-python/commit/cfc97ef55a65e88abb0d76f733cf51dc33823fb0))


### Chores

* **stainless:** generate watch_evictions as an SSE stream ([#10404](https://github.com/runloopai/api-client-python/issues/10404)) ([0413e7b](https://github.com/runloopai/api-client-python/commit/0413e7ba4cd10b919a3fc50e67e0238dc95f4b18))

## 1.24.0 (2026-07-22)

Full Changelog: [v1.23.3...v1.24.0](https://github.com/runloopai/api-client-python/compare/v1.23.3...v1.24.0)
Expand Down
2 changes: 1 addition & 1 deletion api.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Methods:
- <code title="post /v1/devboxes/{id}/suspend">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">suspend</a>(id) -> <a href="./src/runloop_api_client/types/devbox_view.py">DevboxView</a></code>
- <code title="post /v1/devboxes/{id}/upload_file">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">upload_file</a>(id, \*\*<a href="src/runloop_api_client/types/devbox_upload_file_params.py">params</a>) -> object</code>
- <code title="post /v1/devboxes/{devbox_id}/executions/{execution_id}/wait_for_status">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">wait_for_command</a>(execution_id, \*, devbox_id, \*\*<a href="src/runloop_api_client/types/devbox_wait_for_command_params.py">params</a>) -> <a href="./src/runloop_api_client/types/devbox_async_execution_detail_view.py">DevboxAsyncExecutionDetailView</a></code>
- <code title="get /v1/devboxes/watch_evictions">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">watch_evictions</a>() -> <a href="./src/runloop_api_client/types/devbox_eviction_event_view.py">DevboxEvictionEventView</a></code>
- <code title="get /v1/devboxes/evictions/watch">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">watch_evictions</a>() -> <a href="./src/runloop_api_client/types/devbox_eviction_event_view.py">DevboxEvictionEventView</a></code>
- <code title="post /v1/devboxes/{id}/write_file_contents">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">write_file_contents</a>(id, \*\*<a href="src/runloop_api_client/types/devbox_write_file_contents_params.py">params</a>) -> <a href="./src/runloop_api_client/types/devbox_execution_detail_view.py">DevboxExecutionDetailView</a></code>


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "runloop_api_client"
version = "1.24.0"
version = "1.25.0"
description = "The official Python library for the runloop API"
dynamic = ["readme"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/runloop_api_client/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "runloop_api_client"
__version__ = "1.24.0" # x-release-please-version
__version__ = "1.25.0" # x-release-please-version
4 changes: 2 additions & 2 deletions src/runloop_api_client/resources/devboxes/devboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1762,7 +1762,7 @@ def watch_evictions(
and delivery is not guaranteed.
"""
return self._get(
"/v1/devboxes/watch_evictions",
"/v1/devboxes/evictions/watch",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down Expand Up @@ -3464,7 +3464,7 @@ async def watch_evictions(
and delivery is not guaranteed.
"""
return await self._get(
"/v1/devboxes/watch_evictions",
"/v1/devboxes/evictions/watch",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down
2 changes: 2 additions & 0 deletions src/runloop_api_client/sdk/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .async_devbox import AsyncDevbox
from .async_scorer import AsyncScorer
from .async_secret import AsyncSecret
from .async_eviction import shutdown_monitor_for as shutdown_eviction_monitor
from .async_scenario import AsyncScenario
from .async_snapshot import AsyncSnapshot
from .async_benchmark import AsyncBenchmark
Expand Down Expand Up @@ -1373,6 +1374,7 @@ def __init__(

async def aclose(self) -> None:
"""Close the underlying HTTP client and release resources."""
await shutdown_eviction_monitor(self.api)
await self.api.close()

async def __aenter__(self) -> "AsyncRunloopSDK":
Expand Down
20 changes: 20 additions & 0 deletions src/runloop_api_client/sdk/async_devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ._helpers import filter_params
from .._streaming import AsyncStream
from ..lib.polling import PollingConfig
from .async_eviction import AsyncEvictionCallback, monitor_for as _eviction_monitor_for
from ..types.devboxes import ExecutionUpdateChunk
from .async_execution import AsyncExecution, _AsyncStreamingGroup
from .async_execution_result import AsyncExecutionResult
Expand Down Expand Up @@ -216,6 +217,25 @@ async def await_suspended(self, *, polling_config: PollingConfig | None = None)
"""
return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config)

async def on_evict(self, callback: AsyncEvictionCallback) -> None:
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.

The first ``on_evict`` across any devbox on this client opens a single
account-wide notification stream; it closes automatically once every
registered devbox has been notified. The callback runs at most once and is
invoked as ``callback(devbox, eviction_deadline_ms)`` — the devbox itself and
the Unix millisecond deadline by which it will be suspended. Use it to run
cleanup before the devbox is suspended. The callback may be sync or async.

:param callback: Callable invoked with this devbox and its eviction deadline (ms).
:type callback: AsyncEvictionCallback
"""
await _eviction_monitor_for(self._client).register(self, callback)

async def cancel_on_evict(self) -> None:
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
await _eviction_monitor_for(self._client).unregister(self._id)

async def shutdown(
self,
**options: Unpack[LongRequestOptions],
Expand Down
155 changes: 155 additions & 0 deletions src/runloop_api_client/sdk/async_eviction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Async client-side eviction notification monitor.

Async counterpart of :mod:`runloop_api_client.sdk.eviction`; see that module for the
delivery contract. Callbacks may be plain functions or coroutines.
"""

from __future__ import annotations

import asyncio
import inspect
import logging
from typing import TYPE_CHECKING, Dict, Tuple, Union, Callable, Optional, Awaitable
from weakref import WeakKeyDictionary

if TYPE_CHECKING:
from ..types import DevboxEvictionEventView
from .._client import AsyncRunloop
from .._streaming import AsyncStream
from .async_devbox import AsyncDevbox

AsyncEvictionCallback = Callable[["AsyncDevbox", int], Union[None, Awaitable[None]]]
"""Sync or async callable invoked once with the devbox and its eviction deadline (ms)."""

_logger = logging.getLogger(__name__)


class AsyncEvictionMonitor:
"""Async fan-out of account-wide eviction notifications to per-devbox callbacks."""

def __init__(self, client: "AsyncRunloop") -> None:
self._client = client
self._lock = asyncio.Lock()
self._callbacks: Dict[str, Tuple["AsyncDevbox", AsyncEvictionCallback]] = {}
self._task: Optional["asyncio.Task[None]"] = None
self._stream: Optional["AsyncStream[DevboxEvictionEventView]"] = None

async def register(self, devbox: "AsyncDevbox", callback: AsyncEvictionCallback) -> None:
"""Add ``devbox`` to the interest set, starting the stream task if idle."""
async with self._lock:
self._callbacks[devbox.id] = (devbox, callback)
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run(), name="runloop-eviction-monitor")

async def unregister(self, devbox_id: str) -> None:
"""Drop ``devbox_id``; close the stream if it was the last interested devbox."""
async with self._lock:
self._callbacks.pop(devbox_id, None)
empty = not self._callbacks
if empty:
await self._close_stream()

async def close(self) -> None:
"""Clear all interest and tear down the stream."""
async with self._lock:
self._callbacks.clear()
await self._close_stream()

# Reconnect backoff bounds (seconds). The server force-closes the stream on purpose — on a
# leader change (FAILED_PRECONDITION) or a slow consumer (RESOURCE_EXHAUSTED) — and expects the
# client to reconnect and re-read the snapshot, which re-delivers anything missed. So a single
# stream ending is normal, not terminal: reconnect until no devbox is still interested.
_RECONNECT_BACKOFF_INITIAL_S = 0.5
_RECONNECT_BACKOFF_MAX_S = 30.0

async def _run(self) -> None:
backoff = self._RECONNECT_BACKOFF_INITIAL_S
try:
while True:
async with self._lock:
if not self._callbacks:
return
try:
# Force the SSE Accept header: the endpoint only streams for
# text/event-stream; the generated client's default (application/json) gets an
# empty text/plain response, so the feed would silently deliver nothing.
stream = await self._client.devboxes.watch_evictions(extra_headers={"Accept": "text/event-stream"})
async with self._lock:
self._stream = stream
_logger.debug("async eviction monitor stream connected")
async with stream:
async for event in stream:
_logger.debug("async eviction monitor received event for %s", event.devbox_id)
await self._dispatch(event)
async with self._lock:
if not self._callbacks:
return
# Clean end (server closed the stream): reset backoff and reconnect if still
# interested. The reconnect's snapshot re-delivers still-pending evictions.
backoff = self._RECONNECT_BACKOFF_INITIAL_S
_logger.debug("async eviction monitor stream ended; reconnecting")
except asyncio.CancelledError:
raise
except Exception:
# An intentional teardown (close/unregister clears the interest set, then closes
# the stream) surfaces here as a read error — exit quietly in that case.
async with self._lock:
interested = bool(self._callbacks)
if not interested:
return
# Routine: the server force-closes on leader change / slow consumer, and a
# long-lived stream can drop (e.g. an HTTP/2 disconnect). Reconnecting recovers
# it, so keep this at debug to avoid log spam.
_logger.debug("async eviction monitor stream error; reconnecting", exc_info=True)
async with self._lock:
if not self._callbacks:
return
await asyncio.sleep(backoff)
backoff = min(backoff * 2, self._RECONNECT_BACKOFF_MAX_S)
finally:
async with self._lock:
self._stream = None
self._task = None

async def _dispatch(self, event: "DevboxEvictionEventView") -> None:
async with self._lock:
entry = self._callbacks.pop(event.devbox_id, None)
if entry is None:
return
devbox, callback = entry
try:
result = callback(devbox, event.eviction_deadline_ms)
if inspect.isawaitable(result):
await result
except Exception:
_logger.exception("error in eviction callback for devbox %s", event.devbox_id)

async def _close_stream(self) -> None:
async with self._lock:
stream = self._stream
self._stream = None
if stream is not None:
try:
await stream.close()
except Exception:
_logger.debug("error closing eviction stream", exc_info=True)


# asyncio is single-threaded, so the registry needs no lock.
_monitors: "WeakKeyDictionary[AsyncRunloop, AsyncEvictionMonitor]" = WeakKeyDictionary()


def monitor_for(client: "AsyncRunloop") -> AsyncEvictionMonitor:
"""Return the shared :class:`AsyncEvictionMonitor` for ``client``, creating it once."""
monitor = _monitors.get(client)
if monitor is None:
monitor = AsyncEvictionMonitor(client)
_monitors[client] = monitor
return monitor


async def shutdown_monitor_for(client: "AsyncRunloop") -> None:
"""Tear down the shared monitor for ``client`` if one exists."""
monitor = _monitors.pop(client, None)
if monitor is not None:
await monitor.close()
20 changes: 20 additions & 0 deletions src/runloop_api_client/sdk/devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .._types import omit
from .._client import Runloop
from ._helpers import filter_params
from .eviction import EvictionCallback, monitor_for as _eviction_monitor_for
from .execution import Execution, _StreamingGroup
from .._streaming import Stream
from ..lib.polling import PollingConfig
Expand Down Expand Up @@ -219,6 +220,25 @@ def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Dev
"""
return self._client.devboxes.await_suspended(self._id, polling_config=polling_config)

def on_evict(self, callback: EvictionCallback) -> None:
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.

The first ``on_evict`` across any devbox on this client opens a single
account-wide notification stream; it closes automatically once every
registered devbox has been notified. The callback runs at most once and is
invoked as ``callback(devbox, eviction_deadline_ms)`` — the devbox itself and
the Unix millisecond deadline by which it will be suspended. Use it to run
cleanup before the devbox is suspended.

:param callback: Callable invoked with this devbox and its eviction deadline (ms).
:type callback: EvictionCallback
"""
_eviction_monitor_for(self._client).register(self, callback)

def cancel_on_evict(self) -> None:
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
_eviction_monitor_for(self._client).unregister(self._id)

def shutdown(
self,
**options: Unpack[LongRequestOptions],
Expand Down
Loading
Loading