Skip to content

Commit 2a0c6d2

Browse files
committed
feat(sdks/python): resilient SDK transport (OSEP-0016)
Implement the Python side of OSEP-0016 for the sandbox and code-interpreter SDKs. Adds a shared retry policy, extended exception hierarchy, and async/sync httpx transport wrappers. Retry policy - New `RetryPolicy` value type on `ConnectionConfig` / `ConnectionConfigSync`; defaults enable retry for idempotent methods only, `POST`/`PATCH` status-code retry is opt-in. - Default idempotent retryable status set: `{429, 502, 503}`. - Decorrelated jitter (default), full jitter, and deterministic exponential; `Retry-After` honored with a fixed 60s ceiling. - `RetryPolicy.disabled()` gives end-to-end fast-fail semantics and suppresses fresh-connection recovery. - `HTTPStatus` used at the public API surface so callers get type checking and IDE completion for the status sets. Exception hierarchy - Extend `SandboxException` with an `is_retryable` accessor reflecting the SDK's actual retry decision at emission time. - New subclasses: `SandboxRateLimitException` (carries `retry_after`), `SandboxTimeoutException`, `SandboxConnectionException`. Existing classes and their public fields are unchanged. Transport wrappers - `RetryAsyncTransport` (httpx.AsyncBaseTransport) and `RetrySyncTransport` (httpx.BaseTransport) install the retry engine at the same layer the SDK already uses for transport concerns. Generated OpenAPI code is not touched. - `ConnectionConfig` exposes a `sse_transport` property so SSE bootstraps bypass retry while sharing the underlying pool. Command / isolated / code-interpreter SSE clients now use it. - `lifecycle_metrics` keeps its own short-lived httpx.Client; sharing the SDK transport would let the metrics client's `close()` tear down every other adapter's connection pool. Tests - Retry policy defaults, validation, and `HTTPStatus` usage. - Pure decision function: status matrix, transport branch, budget/deadline/cancellation predicates, opt-in rules, jitter algorithms, and `Retry-After` parsing/clamping. - Async + sync httpx transport integration via scripted mock transports covering success, 4xx pass-through, budget exhaustion, pre-send vs. post-send classification, opt-in, and observability callback invariants. - `ConnectionConfig` wiring: default wraps, `disabled()` skips wrapping, user-supplied transports are used verbatim. - SSE clients use the raw inner transport, verified end-to-end.
1 parent b4011b3 commit 2a0c6d2

27 files changed

Lines changed: 2678 additions & 136 deletions

sdks/code-interpreter/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ classifiers = [
4343
]
4444
dependencies = [
4545
"pydantic>=2.4.2,<3.0",
46-
"opensandbox>=0.1.10,<0.2.0",
46+
"opensandbox>=0.1.14,<0.2.0",
4747
]
4848

4949
[project.urls]

sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ def __init__(
130130
"Accept": "text/event-stream",
131131
"Cache-Control": "no-cache",
132132
}
133+
# SSE bootstraps must not be retried; use the raw transport.
134+
# `sse_transport` was introduced in opensandbox 0.1.15; fall
135+
# back to `transport` for older sandbox SDKs still on 0.1.14.
136+
sse_transport = getattr(
137+
self.connection_config, "sse_transport", None
138+
) or self.connection_config.transport
133139
self._sse_client = httpx.AsyncClient(
134140
headers=sse_headers,
135141
timeout=httpx.Timeout(
@@ -138,7 +144,7 @@ def __init__(
138144
write=timeout_seconds,
139145
pool=None,
140146
),
141-
transport=self.connection_config.transport,
147+
transport=sse_transport,
142148
)
143149

144150
async def _get_client(self):

sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,18 @@ def __init__(
118118
"Accept": "text/event-stream",
119119
"Cache-Control": "no-cache",
120120
}
121+
# SSE bootstraps must not be retried; use the raw transport.
122+
# `sse_transport` was introduced in opensandbox 0.1.15; fall
123+
# back to `transport` for older sandbox SDKs still on 0.1.14.
124+
sse_transport = getattr(
125+
self.connection_config, "sse_transport", None
126+
) or self.connection_config.transport
121127
self._sse_client = httpx.Client(
122128
headers=sse_headers,
123129
timeout=httpx.Timeout(
124130
connect=timeout_seconds, read=None, write=timeout_seconds, pool=None
125131
),
126-
transport=self.connection_config.transport,
132+
transport=sse_transport,
127133
)
128134

129135
def _get_execd_url(self, path: str) -> str:

sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
ExecutionEventDispatcher,
4040
)
4141
from opensandbox.adapters.converter.response_handler import (
42-
extract_request_id,
42+
build_api_exception_from_httpx,
4343
handle_api_error,
4444
)
4545
from opensandbox.config import ConnectionConfig
@@ -184,6 +184,9 @@ def __init__(
184184
"Accept": "text/event-stream",
185185
"Cache-Control": "no-cache",
186186
}
187+
# SSE bootstraps must not be retried: bodies are not replayable
188+
# and resume is out of scope here. Share the raw transport so
189+
# the underlying connection pool is still shared.
187190
self._sse_client = httpx.AsyncClient(
188191
headers=sse_headers,
189192
timeout=httpx.Timeout(
@@ -192,7 +195,7 @@ def __init__(
192195
write=timeout_seconds,
193196
pool=None,
194197
),
195-
transport=self.connection_config.transport,
198+
transport=self.connection_config.sse_transport,
196199
)
197200

198201
async def _get_client(self):
@@ -228,15 +231,10 @@ async def _execute_streaming_request(
228231
async with client.stream("POST", url, json=json_body) as response:
229232
if response.status_code != 200:
230233
await response.aread()
231-
error_body = response.text
232234
logger.error(
233-
f"{failure_message}. Status: {response.status_code}, Body: {error_body}"
234-
)
235-
raise SandboxApiException(
236-
message=f"{failure_message}. Status code: {response.status_code}",
237-
status_code=response.status_code,
238-
request_id=extract_request_id(response.headers),
235+
f"{failure_message}. Status: {response.status_code}, Body: {response.text}"
239236
)
237+
raise build_api_exception_from_httpx(response, failure_message)
240238

241239
dispatcher = ExecutionEventDispatcher(execution, handlers)
242240
async for line in response.aiter_lines():

sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,18 @@
2828

2929
import json
3030
import logging
31+
from http import HTTPStatus
3132
from typing import Any
3233

3334
from httpx import (
3435
ConnectError,
36+
ConnectTimeout,
3537
HTTPStatusError,
3638
NetworkError,
39+
PoolTimeout,
3740
ReadTimeout,
3841
TimeoutException,
42+
TransportError,
3943
WriteTimeout,
4044
)
4145

@@ -46,21 +50,18 @@
4650
from opensandbox.exceptions import (
4751
InvalidArgumentException,
4852
SandboxApiException,
53+
SandboxConnectionException,
4954
SandboxError,
5055
SandboxException,
5156
SandboxInternalException,
57+
SandboxRateLimitException,
58+
SandboxTimeoutException,
5259
)
60+
from opensandbox.transport._decision import parse_retry_after
5361

5462
logger = logging.getLogger(__name__)
5563

5664
UNEXPECTED_STATUS_TYPES = (LifecycleUnexpectedStatus, ExecdUnexpectedStatus)
57-
HTTPX_NETWORK_ERROR_TYPES = (
58-
ConnectError,
59-
TimeoutException,
60-
NetworkError,
61-
ReadTimeout,
62-
WriteTimeout,
63-
)
6465

6566

6667
class ExceptionConverter:
@@ -101,20 +102,45 @@ def to_sandbox_exception(e: Exception) -> SandboxException:
101102
if _is_httpx_status_error(e):
102103
return _convert_httpx_error_to_api_exception(e)
103104

104-
# Handle network/IO errors
105-
if isinstance(e, (IOError, OSError, ConnectionError)):
106-
return SandboxInternalException(
105+
# httpx connection failures (DNS, TCP connect, TLS handshake,
106+
# connect timeout) — pre-send, semantically "cannot reach".
107+
# ConnectTimeout must be dispatched before generic TimeoutException
108+
# because it is a subclass of both TimeoutException and ConnectError.
109+
if isinstance(e, (ConnectTimeout, ConnectError, NetworkError)):
110+
return SandboxConnectionException(
107111
message=f"Network connectivity error: {e}",
108112
cause=e,
109113
)
110114

111-
# Handle httpx network errors
112-
if _is_httpx_network_error(e):
115+
# httpx timeout family (read, write, pool, or the synthetic
116+
# ReadTimeout raised by the retry wrapper when overall_deadline
117+
# is exhausted) — the request was sent but did not finish in time.
118+
if isinstance(e, (ReadTimeout, WriteTimeout, PoolTimeout, TimeoutException)):
119+
return SandboxTimeoutException(
120+
message=f"Request timed out: {e}",
121+
cause=e,
122+
)
123+
124+
# OS-level connectivity errors (DNS, socket errors, etc.) not
125+
# already routed through httpx's typed hierarchy.
126+
if isinstance(e, (ConnectionError,)):
127+
return SandboxConnectionException(
128+
message=f"Network connectivity error: {e}",
129+
cause=e,
130+
)
131+
if isinstance(e, (IOError, OSError)):
113132
return SandboxInternalException(
114133
message=f"Network connectivity error: {e}",
115134
cause=e,
116135
)
117136

137+
# Any other httpx TransportError (opaque low-level failures).
138+
if isinstance(e, TransportError):
139+
return SandboxConnectionException(
140+
message=f"Network connectivity error: {e}",
141+
cause=e,
142+
)
143+
118144
# Handle validation and argument errors (SDK usage errors)
119145
# - ValueError/TypeError are typically raised for invalid user inputs or model validation
120146
# - Pydantic ValidationError represents invalid input data for SDK models
@@ -154,24 +180,57 @@ def _is_httpx_status_error(e: Exception) -> bool:
154180
return isinstance(e, HTTPStatusError)
155181

156182

157-
def _is_httpx_network_error(e: Exception) -> bool:
158-
"""Check if exception is an httpx network-related error."""
159-
return isinstance(e, HTTPX_NETWORK_ERROR_TYPES)
183+
def _retry_after_from_headers(headers: Any) -> float | None:
184+
if not headers:
185+
return None
186+
try:
187+
raw = headers.get("Retry-After") or headers.get("retry-after")
188+
except Exception:
189+
return None
190+
parsed = parse_retry_after(raw if isinstance(raw, str) else None)
191+
return parsed.total_seconds() if parsed is not None else None
192+
193+
194+
def _build_api_exception(
195+
*,
196+
status_code: int,
197+
content: bytes | None,
198+
cause: Exception,
199+
request_id: str | None = None,
200+
retry_after: float | None = None,
201+
) -> SandboxApiException:
202+
"""Build a Sandbox(ApiException|RateLimitException) from raw fields."""
203+
sandbox_error = _parse_error_body(content) if content else None
204+
if status_code == HTTPStatus.TOO_MANY_REQUESTS:
205+
return SandboxRateLimitException(
206+
message=f"API error: HTTP {status_code}",
207+
status_code=status_code,
208+
cause=cause,
209+
error=sandbox_error,
210+
request_id=request_id,
211+
retry_after=retry_after,
212+
response_body=content if isinstance(content, bytes) else None,
213+
)
214+
return SandboxApiException(
215+
message=f"API error: HTTP {status_code}",
216+
status_code=status_code,
217+
cause=cause,
218+
error=sandbox_error,
219+
request_id=request_id,
220+
response_body=content if isinstance(content, bytes) else None,
221+
)
160222

161223

162224
def _convert_unexpected_status_to_api_exception(e: Exception) -> SandboxApiException:
163225
"""Convert openapi-python-client UnexpectedStatus to SandboxApiException."""
164226
status_code = getattr(e, "status_code", 0)
165227
content = getattr(e, "content", b"")
166-
167-
# Try to parse error body
168-
sandbox_error = _parse_error_body(content)
169-
170-
return SandboxApiException(
171-
message=f"API error: HTTP {status_code}",
172-
status_code=status_code,
228+
# openapi-python-client's UnexpectedStatus does not carry headers,
229+
# so Retry-After / request_id are not recoverable here.
230+
return _build_api_exception(
231+
status_code=int(status_code) if status_code else 0,
232+
content=content if isinstance(content, bytes) else None,
173233
cause=e,
174-
error=sandbox_error,
175234
)
176235

177236

@@ -181,20 +240,19 @@ def _convert_httpx_error_to_api_exception(e: Exception) -> SandboxApiException:
181240
status_code = response.status_code if response else 0
182241
content = response.content if response else b""
183242
request_id = None
243+
retry_after = None
184244
if response is not None:
185245
from opensandbox.adapters.converter.response_handler import extract_request_id
186246

187247
request_id = extract_request_id(response.headers)
248+
retry_after = _retry_after_from_headers(response.headers)
188249

189-
# Try to parse error body
190-
sandbox_error = _parse_error_body(content)
191-
192-
return SandboxApiException(
193-
message=f"API error: HTTP {status_code}",
194-
status_code=status_code,
250+
return _build_api_exception(
251+
status_code=int(status_code) if status_code else 0,
252+
content=content if isinstance(content, bytes) else None,
195253
cause=e,
196-
error=sandbox_error,
197254
request_id=request_id,
255+
retry_after=retry_after,
198256
)
199257

200258

0 commit comments

Comments
 (0)