Skip to content

Commit 3443be0

Browse files
author
Reflex
committed
fix(tunnel): poll established service readiness
1 parent 44e6b62 commit 3443be0

5 files changed

Lines changed: 299 additions & 76 deletions

File tree

src/runloop_api_client/lib/error_contract.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from __future__ import annotations
88

9+
import time
10+
import email.utils
911
from typing import Mapping, cast
1012
from dataclasses import dataclass
1113

@@ -37,6 +39,9 @@ def parse_retry_after(headers: httpx.Headers, body: object = None) -> float | No
3739
seconds = _number(headers.get("retry-after"))
3840
if seconds is not None:
3941
return seconds
42+
retry_date = email.utils.parsedate_tz(headers.get("retry-after"))
43+
if retry_date is not None:
44+
return max(float(email.utils.mktime_tz(retry_date) - time.time()), 0)
4045
if isinstance(body, Mapping):
4146
payload = cast(Mapping[str, object], body)
4247
details = payload.get("details")
Lines changed: 121 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,167 @@
1-
"""Bounded retry helpers for tunnel service readiness."""
1+
"""Bounded polling of an established tunnel's service endpoint."""
22

33
from __future__ import annotations
44

5+
import json
56
import time
6-
import inspect
7-
from typing import TypeVar, Callable, Awaitable
7+
from typing import Mapping, Callable, Awaitable, cast
88

9-
from .._exceptions import APIStatusError
9+
import httpx
1010

11-
T = TypeVar("T")
11+
from .._exceptions import APIError, APIStatusError, APITimeoutError, APIConnectionError
12+
from .error_contract import is_safe_transport_retry
1213

1314

14-
def _timeout(error: APIStatusError, *, port: int, path: str, timeout_seconds: float, attempts: int) -> None:
15+
def tunnel_url(*, api_host: str, tunnel_key: str, port: int, path: str = "/") -> str:
16+
"""Construct the established tunnel URL using the SDK's domain convention."""
17+
if not 1 <= port <= 65535:
18+
raise ValueError("port must be between 1 and 65535")
19+
if not path.startswith("/"):
20+
raise ValueError("path must start with '/'")
21+
if not tunnel_key or not all(
22+
character.isascii() and (character.isalnum() or character in "-_") for character in tunnel_key
23+
):
24+
raise ValueError("tunnel_key contains characters that are unsafe in a tunnel hostname")
25+
base_domain = api_host[4:] if api_host.startswith("api.") else api_host
26+
return f"https://{port}-{tunnel_key}.tunnel.{base_domain}{path}"
27+
28+
29+
def tunnel_auth_headers(*, auth_mode: str, auth_token: str | None, request: httpx.Request) -> Mapping[str, str]:
30+
"""Return tunnel authentication without leaking the Runloop API bearer token."""
31+
if auth_mode != "authenticated":
32+
return {}
33+
if auth_token:
34+
return {"X-Runloop-Tunnel-Authorization": f"Bearer {auth_token}"}
35+
error = APIConnectionError(
36+
message="Authenticated tunnel is missing its tunnel authorization token.",
37+
request=request,
38+
)
39+
error.code = "tunnel_authentication_required"
40+
error.phase = "tunnel_readiness"
41+
error.retryable = False
42+
raise error
43+
44+
45+
def _status_error(response: httpx.Response, attempts: int) -> APIStatusError:
46+
body: object = response.text
47+
try:
48+
body = cast(object, json.loads(response.text))
49+
except (TypeError, ValueError):
50+
pass
51+
payload = cast(Mapping[str, object], body) if isinstance(body, dict) else None
52+
body_message = payload.get("message") if payload is not None else None
53+
message = (
54+
body_message
55+
if isinstance(body_message, str)
56+
else f"Tunnel readiness check failed with HTTP status {response.status_code}."
57+
)
58+
error = APIStatusError(message, response=response, body=cast(object, body), attempts=attempts)
59+
if error.phase == "api":
60+
error.phase = "tunnel_readiness"
61+
return error
62+
63+
64+
def _connection_error(error: httpx.HTTPError, request: httpx.Request, attempts: int) -> APIConnectionError:
65+
if isinstance(error, httpx.TimeoutException):
66+
return APITimeoutError(request=request, cause=error, attempts=attempts)
67+
return APIConnectionError(request=request, cause=error, attempts=attempts)
68+
69+
70+
def _raise_timeout(error: APIError, *, port: int, path: str, timeout_seconds: float, attempts: int) -> None:
1571
message = f"Tunnel service was not ready for port {port} path {path!r} within {timeout_seconds:g} seconds."
1672
error.message = message
1773
error.args = (message,)
1874
error.attempts = attempts
1975
raise error
2076

2177

78+
def _retry_delay(error: APIError) -> float:
79+
return error.retry_after if error.retry_after is not None else 0.5
80+
81+
82+
def _is_transient_status(error: APIStatusError) -> bool:
83+
if error.code == "tunnel_unavailable":
84+
return False
85+
return error.code == "tunnel_service_not_ready" and error.response.headers.get("x-should-retry") != "false"
86+
87+
2288
def wait_for_tunnel_service(
23-
operation: Callable[[], T],
89+
request: Callable[[float], httpx.Response],
2490
*,
2591
port: int,
2692
path: str = "/",
2793
timeout_seconds: float = 30.0,
2894
clock: Callable[[], float] = time.monotonic,
2995
sleep: Callable[[float], None] = time.sleep,
30-
) -> T:
31-
"""Retry only ``tunnel_service_not_ready`` until a bounded deadline."""
96+
) -> None:
97+
"""Poll an established tunnel URL until it returns a successful response."""
3298
if timeout_seconds <= 0:
3399
raise ValueError("timeout_seconds must be greater than zero")
34100
deadline = clock() + timeout_seconds
35101
attempts = 0
102+
failure: APIError | None = None
36103
while True:
104+
remaining = deadline - clock()
105+
if remaining <= 0:
106+
if failure is None:
107+
raise ValueError("tunnel readiness deadline expired before the first request")
108+
_raise_timeout(failure, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
37109
attempts += 1
38110
try:
39-
return operation()
40-
except APIStatusError as error:
41-
error.attempts = attempts
42-
if error.code != "tunnel_service_not_ready":
43-
raise
44-
remaining = deadline - clock()
45-
if remaining <= 0 or attempts >= 1000:
46-
_timeout(error, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
47-
delay = error.retry_after if error.retry_after is not None else 0.5
48-
sleep(min(max(delay, 0), remaining))
111+
response = request(remaining)
112+
if response.is_success:
113+
return
114+
failure = _status_error(response, attempts)
115+
if not _is_transient_status(failure):
116+
raise failure
117+
except httpx.HTTPError as cause:
118+
request_object = cause.request
119+
failure = _connection_error(cause, request_object, attempts)
120+
if not is_safe_transport_retry(cause):
121+
raise failure from cause
122+
123+
remaining = deadline - clock()
124+
if remaining <= 0 or attempts >= 1000:
125+
_raise_timeout(failure, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
126+
sleep(min(max(_retry_delay(failure), 0), remaining))
49127

50128

51129
async def async_wait_for_tunnel_service(
52-
operation: Callable[[], Awaitable[T]],
130+
request: Callable[[float], Awaitable[httpx.Response]],
53131
*,
54132
port: int,
55133
path: str = "/",
56134
timeout_seconds: float = 30.0,
57135
clock: Callable[[], float] = time.monotonic,
58136
sleep: Callable[[float], Awaitable[None]],
59-
) -> T:
137+
) -> None:
60138
"""Async counterpart to :func:`wait_for_tunnel_service`."""
61139
if timeout_seconds <= 0:
62140
raise ValueError("timeout_seconds must be greater than zero")
63141
deadline = clock() + timeout_seconds
64142
attempts = 0
143+
failure: APIError | None = None
65144
while True:
145+
remaining = deadline - clock()
146+
if remaining <= 0:
147+
if failure is None:
148+
raise ValueError("tunnel readiness deadline expired before the first request")
149+
_raise_timeout(failure, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
66150
attempts += 1
67151
try:
68-
return await operation()
69-
except APIStatusError as error:
70-
error.attempts = attempts
71-
if error.code != "tunnel_service_not_ready":
72-
raise
73-
remaining = deadline - clock()
74-
if remaining <= 0 or attempts >= 1000:
75-
_timeout(error, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
76-
delay = error.retry_after if error.retry_after is not None else 0.5
77-
result = sleep(min(max(delay, 0), remaining))
78-
if inspect.isawaitable(result):
79-
await result
152+
response = await request(remaining)
153+
if response.is_success:
154+
return
155+
failure = _status_error(response, attempts)
156+
if not _is_transient_status(failure):
157+
raise failure
158+
except httpx.HTTPError as cause:
159+
request_object = cause.request
160+
failure = _connection_error(cause, request_object, attempts)
161+
if not is_safe_transport_retry(cause):
162+
raise failure from cause
163+
164+
remaining = deadline - clock()
165+
if remaining <= 0 or attempts >= 1000:
166+
_raise_timeout(failure, port=port, path=path, timeout_seconds=timeout_seconds, attempts=attempts)
167+
await sleep(min(max(_retry_delay(failure), 0), remaining))

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast
99
from typing_extensions import Unpack, override
1010

11+
import httpx
12+
1113
from ..types import (
1214
DevboxView,
1315
TunnelView,
@@ -38,7 +40,7 @@
3840
from ..lib.polling import PollingConfig
3941
from ..types.devboxes import ExecutionUpdateChunk
4042
from .async_execution import AsyncExecution, _AsyncStreamingGroup
41-
from ..lib.tunnel_readiness import async_wait_for_tunnel_service
43+
from ..lib.tunnel_readiness import tunnel_url, tunnel_auth_headers, async_wait_for_tunnel_service
4244
from .async_execution_result import AsyncExecutionResult
4345
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
4446
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
@@ -838,20 +840,36 @@ async def wait_for_tunnel_ready(
838840
path: str = "/",
839841
*,
840842
timeout_seconds: float = 30.0,
843+
http_client: httpx.AsyncClient | None = None,
841844
clock: Callable[[], float] = time.monotonic,
842845
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
843846
**params: Unpack[SDKDevboxEnableTunnelParams],
844847
) -> TunnelView:
845-
"""Enable a tunnel, waiting through transient service readiness failures."""
848+
"""Enable a tunnel and poll the requested service until it is ready."""
846849
client = self._devbox._client.with_options(max_retries=0)
847-
return await async_wait_for_tunnel_service(
848-
lambda: client.devboxes.enable_tunnel(self._devbox.id, **params),
849-
port=port,
850-
path=path,
851-
timeout_seconds=timeout_seconds,
852-
clock=clock,
853-
sleep=sleep,
850+
enable_params: dict[str, Any] = dict(params)
851+
enable_params.setdefault("timeout", timeout_seconds)
852+
tunnel = await client.devboxes.enable_tunnel(self._devbox.id, **enable_params)
853+
url = tunnel_url(api_host=client.base_url.host, tunnel_key=tunnel.tunnel_key, port=port, path=path)
854+
headers = tunnel_auth_headers(
855+
auth_mode=tunnel.auth_mode,
856+
auth_token=tunnel.auth_token,
857+
request=httpx.Request("GET", url),
854858
)
859+
probe_client = http_client or httpx.AsyncClient(follow_redirects=True)
860+
try:
861+
await async_wait_for_tunnel_service(
862+
lambda remaining: probe_client.get(url, headers=headers, timeout=remaining, follow_redirects=True),
863+
port=port,
864+
path=path,
865+
timeout_seconds=timeout_seconds,
866+
clock=clock,
867+
sleep=sleep,
868+
)
869+
finally:
870+
if http_client is None:
871+
await probe_client.aclose()
872+
return tunnel
855873

856874
async def remove_tunnel(
857875
self,

src/runloop_api_client/sdk/devbox.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence
99
from typing_extensions import Unpack, override
1010

11+
import httpx
12+
1113
from ..types import (
1214
DevboxView,
1315
TunnelView,
@@ -40,7 +42,7 @@
4042
from ..lib.polling import PollingConfig
4143
from ..types.devboxes import ExecutionUpdateChunk
4244
from .execution_result import ExecutionResult
43-
from ..lib.tunnel_readiness import wait_for_tunnel_service
45+
from ..lib.tunnel_readiness import tunnel_url, tunnel_auth_headers, wait_for_tunnel_service
4446
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
4547
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
4648
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
@@ -841,24 +843,40 @@ def wait_for_tunnel_ready(
841843
path: str = "/",
842844
*,
843845
timeout_seconds: float = 30.0,
846+
http_client: httpx.Client | None = None,
844847
clock: Callable[[], float] = time.monotonic,
845848
sleep: Callable[[float], None] = time.sleep,
846849
**params: Unpack[SDKDevboxEnableTunnelParams],
847850
) -> TunnelView:
848-
"""Enable a tunnel, waiting through transient service readiness failures.
851+
"""Enable a tunnel and poll the requested service until it is ready.
849852
850-
The generated client's own retries are disabled so this bounded helper
851-
owns the deadline and preserves the final normalized error.
853+
The readiness probe uses the tunnel-specific authorization token when
854+
required and never forwards the Runloop API bearer token.
852855
"""
853856
client = self._devbox._client.with_options(max_retries=0)
854-
return wait_for_tunnel_service(
855-
lambda: client.devboxes.enable_tunnel(self._devbox.id, **params),
856-
port=port,
857-
path=path,
858-
timeout_seconds=timeout_seconds,
859-
clock=clock,
860-
sleep=sleep,
857+
enable_params: dict[str, Any] = dict(params)
858+
enable_params.setdefault("timeout", timeout_seconds)
859+
tunnel = client.devboxes.enable_tunnel(self._devbox.id, **enable_params)
860+
url = tunnel_url(api_host=client.base_url.host, tunnel_key=tunnel.tunnel_key, port=port, path=path)
861+
headers = tunnel_auth_headers(
862+
auth_mode=tunnel.auth_mode,
863+
auth_token=tunnel.auth_token,
864+
request=httpx.Request("GET", url),
861865
)
866+
probe_client = http_client or httpx.Client(follow_redirects=True)
867+
try:
868+
wait_for_tunnel_service(
869+
lambda remaining: probe_client.get(url, headers=headers, timeout=remaining, follow_redirects=True),
870+
port=port,
871+
path=path,
872+
timeout_seconds=timeout_seconds,
873+
clock=clock,
874+
sleep=sleep,
875+
)
876+
finally:
877+
if http_client is None:
878+
probe_client.close()
879+
return tunnel
862880

863881
def remove_tunnel(
864882
self,

0 commit comments

Comments
 (0)