|
1 | | -"""Bounded retry helpers for tunnel service readiness.""" |
| 1 | +"""Bounded polling of an established tunnel's service endpoint.""" |
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import json |
5 | 6 | import time |
6 | | -import inspect |
7 | | -from typing import TypeVar, Callable, Awaitable |
| 7 | +from typing import Mapping, Callable, Awaitable, cast |
8 | 8 |
|
9 | | -from .._exceptions import APIStatusError |
| 9 | +import httpx |
10 | 10 |
|
11 | | -T = TypeVar("T") |
| 11 | +from .._exceptions import APIError, APIStatusError, APITimeoutError, APIConnectionError |
| 12 | +from .error_contract import is_safe_transport_retry |
12 | 13 |
|
13 | 14 |
|
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: |
15 | 71 | message = f"Tunnel service was not ready for port {port} path {path!r} within {timeout_seconds:g} seconds." |
16 | 72 | error.message = message |
17 | 73 | error.args = (message,) |
18 | 74 | error.attempts = attempts |
19 | 75 | raise error |
20 | 76 |
|
21 | 77 |
|
| 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 | + |
22 | 88 | def wait_for_tunnel_service( |
23 | | - operation: Callable[[], T], |
| 89 | + request: Callable[[float], httpx.Response], |
24 | 90 | *, |
25 | 91 | port: int, |
26 | 92 | path: str = "/", |
27 | 93 | timeout_seconds: float = 30.0, |
28 | 94 | clock: Callable[[], float] = time.monotonic, |
29 | 95 | 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.""" |
32 | 98 | if timeout_seconds <= 0: |
33 | 99 | raise ValueError("timeout_seconds must be greater than zero") |
34 | 100 | deadline = clock() + timeout_seconds |
35 | 101 | attempts = 0 |
| 102 | + failure: APIError | None = None |
36 | 103 | 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) |
37 | 109 | attempts += 1 |
38 | 110 | 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)) |
49 | 127 |
|
50 | 128 |
|
51 | 129 | async def async_wait_for_tunnel_service( |
52 | | - operation: Callable[[], Awaitable[T]], |
| 130 | + request: Callable[[float], Awaitable[httpx.Response]], |
53 | 131 | *, |
54 | 132 | port: int, |
55 | 133 | path: str = "/", |
56 | 134 | timeout_seconds: float = 30.0, |
57 | 135 | clock: Callable[[], float] = time.monotonic, |
58 | 136 | sleep: Callable[[float], Awaitable[None]], |
59 | | -) -> T: |
| 137 | +) -> None: |
60 | 138 | """Async counterpart to :func:`wait_for_tunnel_service`.""" |
61 | 139 | if timeout_seconds <= 0: |
62 | 140 | raise ValueError("timeout_seconds must be greater than zero") |
63 | 141 | deadline = clock() + timeout_seconds |
64 | 142 | attempts = 0 |
| 143 | + failure: APIError | None = None |
65 | 144 | 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) |
66 | 150 | attempts += 1 |
67 | 151 | 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)) |
0 commit comments