Skip to content

Commit 7e1af4c

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 7e1af4c

26 files changed

Lines changed: 2287 additions & 32 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.15,<0.2.0",
4747
]
4848

4949
[project.urls]

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def __init__(
130130
"Accept": "text/event-stream",
131131
"Cache-Control": "no-cache",
132132
}
133+
# SSE bootstraps must not be retried; use the raw transport.
133134
self._sse_client = httpx.AsyncClient(
134135
headers=sse_headers,
135136
timeout=httpx.Timeout(
@@ -138,7 +139,7 @@ def __init__(
138139
write=timeout_seconds,
139140
pool=None,
140141
),
141-
transport=self.connection_config.transport,
142+
transport=self.connection_config.sse_transport,
142143
)
143144

144145
async def _get_client(self):

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,13 @@ def __init__(
118118
"Accept": "text/event-stream",
119119
"Cache-Control": "no-cache",
120120
}
121+
# SSE bootstraps must not be retried; use the raw transport.
121122
self._sse_client = httpx.Client(
122123
headers=sse_headers,
123124
timeout=httpx.Timeout(
124125
connect=timeout_seconds, read=None, write=timeout_seconds, pool=None
125126
),
126-
transport=self.connection_config.transport,
127+
transport=self.connection_config.sse_transport,
127128
)
128129

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

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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):

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

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
from http import HTTPStatus
2929
from typing import Any, TypeVar
3030

31-
from opensandbox.exceptions import SandboxApiException, SandboxError
31+
from opensandbox.exceptions import (
32+
SandboxApiException,
33+
SandboxError,
34+
SandboxRateLimitException,
35+
)
36+
from opensandbox.transport._decision import parse_retry_after
3237

3338
logger = logging.getLogger(__name__)
3439

@@ -98,6 +103,49 @@ def require_parsed(response_obj: Any, expected_type: type[T], operation_name: st
98103
return parsed
99104

100105

106+
def _retry_after_seconds(headers: Any) -> float | None:
107+
"""Return Retry-After (seconds) from response headers, or None."""
108+
if not headers:
109+
return None
110+
try:
111+
raw = headers.get("Retry-After") or headers.get("retry-after")
112+
except Exception:
113+
return None
114+
parsed = parse_retry_after(raw if isinstance(raw, str) else None)
115+
return parsed.total_seconds() if parsed is not None else None
116+
117+
118+
# Upper bound on the raw response body slice we splice into an
119+
# exception's ``str()``. The full body is always available on
120+
# ``exc.response_body`` untruncated.
121+
_RAW_BODY_MESSAGE_LIMIT = 512
122+
123+
124+
def _raw_body_bytes(response_obj: Any) -> bytes | None:
125+
"""Return the response's raw body as ``bytes`` when available."""
126+
content = getattr(response_obj, "content", None)
127+
if isinstance(content, bytes):
128+
return content
129+
if isinstance(content, bytearray):
130+
return bytes(content)
131+
return None
132+
133+
134+
def _raw_body_message_fragment(body: bytes | None) -> str | None:
135+
"""Best-effort decode of the raw body for splicing into an error message."""
136+
if not body:
137+
return None
138+
try:
139+
text = body.decode("utf-8", errors="replace").strip()
140+
except Exception:
141+
return None
142+
if not text:
143+
return None
144+
if len(text) > _RAW_BODY_MESSAGE_LIMIT:
145+
text = text[:_RAW_BODY_MESSAGE_LIMIT] + "…"
146+
return text
147+
148+
101149
def handle_api_error(response_obj: Any, operation_name: str = "API call") -> None:
102150
"""
103151
Check API response for errors and raise exception if needed.
@@ -109,10 +157,13 @@ def handle_api_error(response_obj: Any, operation_name: str = "API call") -> Non
109157
operation_name: Name of the operation for error messages
110158
111159
Raises:
112-
SandboxApiException: If the response indicates an error
160+
SandboxRateLimitException: On HTTP 429 (Too Many Requests).
161+
SandboxApiException: On any other HTTP >= 300.
113162
"""
114163
status_code = _status_code_to_int(getattr(response_obj, "status_code", 0))
115-
request_id = extract_request_id(getattr(response_obj, "headers", None))
164+
headers = getattr(response_obj, "headers", None)
165+
request_id = extract_request_id(headers)
166+
raw_body = _raw_body_bytes(response_obj)
116167

117168
logger.debug(f"{operation_name} response: status={status_code}")
118169

@@ -136,9 +187,28 @@ def handle_api_error(response_obj: Any, operation_name: str = "API call") -> Non
136187
message=str(parsed_message or ""),
137188
)
138189

190+
# Fall back to the raw body when the SDK could not parse a
191+
# structured message/code, so callers do not lose the server's
192+
# own explanation.
193+
if sandbox_error is None:
194+
raw_fragment = _raw_body_message_fragment(raw_body)
195+
if raw_fragment:
196+
error_message = f"{error_message}: {raw_fragment}"
197+
198+
if status_code == HTTPStatus.TOO_MANY_REQUESTS:
199+
raise SandboxRateLimitException(
200+
message=error_message,
201+
status_code=status_code,
202+
request_id=request_id,
203+
retry_after=_retry_after_seconds(headers),
204+
error=sandbox_error,
205+
response_body=raw_body,
206+
)
207+
139208
raise SandboxApiException(
140209
message=error_message,
141210
status_code=status_code,
142211
request_id=request_id,
143212
error=sandbox_error,
213+
response_body=raw_body,
144214
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ def __init__(
231231
"Accept": "text/event-stream",
232232
"Cache-Control": "no-cache",
233233
}
234+
# SSE bootstraps must not be retried; use the raw transport.
234235
self._sse_client = httpx.AsyncClient(
235236
headers=sse_headers,
236237
timeout=httpx.Timeout(
@@ -239,7 +240,7 @@ def __init__(
239240
write=timeout_seconds,
240241
pool=None,
241242
),
242-
transport=self.connection_config.transport,
243+
transport=self.connection_config.sse_transport,
243244
)
244245

245246
def _get_url(self, path: str) -> str:

sdks/sandbox/python/src/opensandbox/config/connection.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
field_validator,
3030
)
3131

32+
from opensandbox.transport import RetryAsyncTransport, RetryPolicy
33+
3234

3335
class ConnectionConfig(BaseModel):
3436
"""
@@ -49,6 +51,7 @@ class ConnectionConfig(BaseModel):
4951
model_config = ConfigDict(arbitrary_types_allowed=True)
5052

5153
_owns_transport: bool = PrivateAttr(default=True)
54+
_sse_transport: httpx.AsyncBaseTransport | None = PrivateAttr(default=None)
5255

5356
api_key: str | None = Field(
5457
default=None, description="API key for authentication with sandbox service"
@@ -73,9 +76,17 @@ class ConnectionConfig(BaseModel):
7376
transport: httpx.AsyncBaseTransport | None = Field(
7477
default=None,
7578
description=(
76-
"Shared httpx transport instance used by all HTTP clients within a "
77-
"Sandbox/Manager instance. Pass a custom transport (e.g. AsyncHTTPTransport "
78-
"with custom settings) to control connection pooling, proxies, retries, etc."
79+
"Shared httpx transport for non-SSE clients. When unset the SDK "
80+
"builds an AsyncHTTPTransport wrapped by RetryAsyncTransport "
81+
"honoring `retry_policy`. Pass a custom transport to override "
82+
"the whole stack (retry wrapping is then the caller's job)."
83+
),
84+
)
85+
retry_policy: RetryPolicy = Field(
86+
default_factory=RetryPolicy,
87+
description=(
88+
"Retry policy applied to non-SSE requests. "
89+
"Pass RetryPolicy.disabled() for fast-fail semantics."
7990
),
8091
)
8192
use_server_proxy: bool = Field(
@@ -114,6 +125,10 @@ class ConnectionConfig(BaseModel):
114125
def model_post_init(self, __context: object) -> None:
115126
# If the user explicitly provided `transport`, the SDK must not close it.
116127
self._owns_transport = "transport" not in self.model_fields_set
128+
# A caller-supplied transport is used verbatim for both normal
129+
# and SSE clients; we cannot know whether it retries already.
130+
if self.transport is not None:
131+
self._sse_transport = self.transport
117132
# Best-effort: attach the SDK host's own IP so the server can see the
118133
# client's self-reported address. Never overrides a user-supplied value
119134
# and is skipped silently when the IP cannot be determined.
@@ -125,30 +140,56 @@ def with_transport_if_missing(self) -> "ConnectionConfig":
125140
"""
126141
Ensure a transport exists for this SDK resource.
127142
128-
If `transport` is missing, return a copy with a default transport and
129-
mark it as SDK-owned. If present, return self unchanged.
143+
When `transport` is missing, return a copy whose `transport` is
144+
retry-wrapped and whose `sse_transport` is the raw inner
145+
transport. When present, return self unchanged.
130146
"""
131147
if self.transport is not None:
132148
return self
133-
transport = httpx.AsyncHTTPTransport(
149+
inner = httpx.AsyncHTTPTransport(
134150
limits=httpx.Limits(
135151
max_connections=100,
136152
max_keepalive_connections=20,
137153
keepalive_expiry=30.0,
138154
),
139155
)
140-
config = self.model_copy(update={"transport": transport})
156+
wrapped: httpx.AsyncBaseTransport
157+
if self.retry_policy.wraps_transport():
158+
# Wrapper is required either for retries or purely to enforce
159+
# per_attempt_timeout / overall_deadline / on_retry hooks.
160+
wrapped = RetryAsyncTransport(
161+
inner, self.retry_policy, owns_inner=False
162+
)
163+
else:
164+
# Fully disabled policy with no timeout/deadline knobs: skip
165+
# wrapping so fast-fail is truly end-to-end.
166+
wrapped = inner
167+
config = self.model_copy(update={"transport": wrapped})
141168
config._owns_transport = True
169+
config._sse_transport = inner
142170
return config
143171

172+
@property
173+
def sse_transport(self) -> httpx.AsyncBaseTransport | None:
174+
"""Transport for SSE clients. Never retry-wrapped, falls back to `transport`."""
175+
if self._sse_transport is not None:
176+
return self._sse_transport
177+
return self.transport
178+
144179
async def close_transport_if_owned(self) -> None:
145180
"""Close the transport only if it was created by default_factory."""
146-
if self.transport is None or not self._owns_transport:
181+
if not self._owns_transport:
182+
return
183+
# The connection pool lives on the inner (SSE) transport. The
184+
# retry wrapper's aclose is a no-op when it does not own its
185+
# inner, so closing whichever we own is enough.
186+
target = self._sse_transport or self.transport
187+
if target is None:
147188
return
148189
try:
149-
await self.transport.aclose()
190+
await target.aclose()
150191
except Exception:
151-
# Avoid raising during cleanup paths
192+
# Avoid raising during cleanup paths.
152193
pass
153194

154195
@field_validator("protocol")

sdks/sandbox/python/src/opensandbox/config/connection_sync.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import httpx
2626
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
2727

28+
from opensandbox.transport import RetryPolicy, RetrySyncTransport
29+
2830

2931
class ConnectionConfigSync(BaseModel):
3032
"""
@@ -39,6 +41,7 @@ class ConnectionConfigSync(BaseModel):
3941
model_config = ConfigDict(arbitrary_types_allowed=True)
4042

4143
_owns_transport: bool = PrivateAttr(default=True)
44+
_sse_transport: httpx.BaseTransport | None = PrivateAttr(default=None)
4245

4346
api_key: str | None = Field(
4447
default=None, description="API key for authentication with sandbox service"
@@ -60,9 +63,16 @@ class ConnectionConfigSync(BaseModel):
6063
transport: httpx.BaseTransport | None = Field(
6164
default=None,
6265
description=(
63-
"Shared httpx transport instance used by all HTTP clients within a "
64-
"Sandbox/Manager instance. Pass a custom transport (e.g. HTTPTransport "
65-
"with custom limits/proxies) to control connection pooling, proxies, retries, etc."
66+
"Shared httpx transport for non-SSE clients. When unset the SDK "
67+
"builds an HTTPTransport wrapped by RetrySyncTransport honoring "
68+
"`retry_policy`."
69+
),
70+
)
71+
retry_policy: RetryPolicy = Field(
72+
default_factory=RetryPolicy,
73+
description=(
74+
"Retry policy applied to non-SSE requests. "
75+
"Pass RetryPolicy.disabled() for fast-fail semantics."
6676
),
6777
)
6878
use_server_proxy: bool = Field(
@@ -99,6 +109,8 @@ class ConnectionConfigSync(BaseModel):
99109

100110
def model_post_init(self, __context: object) -> None:
101111
self._owns_transport = "transport" not in self.model_fields_set
112+
if self.transport is not None:
113+
self._sse_transport = self.transport
102114
# Best-effort: attach the SDK host's own IP (see async ConnectionConfig).
103115
from opensandbox.config import client_ip
104116

@@ -108,28 +120,47 @@ def with_transport_if_missing(self) -> "ConnectionConfigSync":
108120
"""
109121
Ensure a transport exists for this SDK resource.
110122
111-
If `transport` is missing, return a copy with a default transport and
112-
mark it as SDK-owned. If present, return self unchanged.
123+
When `transport` is missing, return a copy whose `transport` is
124+
retry-wrapped and whose `sse_transport` is the raw inner
125+
transport. When present, return self unchanged.
113126
"""
114127
if self.transport is not None:
115128
return self
116-
transport = httpx.HTTPTransport(
129+
inner = httpx.HTTPTransport(
117130
limits=httpx.Limits(
118131
max_connections=100,
119132
max_keepalive_connections=20,
120133
keepalive_expiry=30.0,
121134
),
122135
)
123-
config = self.model_copy(update={"transport": transport})
136+
wrapped: httpx.BaseTransport
137+
if self.retry_policy.wraps_transport():
138+
wrapped = RetrySyncTransport(
139+
inner, self.retry_policy, owns_inner=False
140+
)
141+
else:
142+
wrapped = inner
143+
config = self.model_copy(update={"transport": wrapped})
124144
config._owns_transport = True
145+
config._sse_transport = inner
125146
return config
126147

148+
@property
149+
def sse_transport(self) -> httpx.BaseTransport | None:
150+
"""Transport for SSE clients. Never retry-wrapped, falls back to `transport`."""
151+
if self._sse_transport is not None:
152+
return self._sse_transport
153+
return self.transport
154+
127155
def close_transport_if_owned(self) -> None:
128156
"""Close the transport only if it was created by default_factory."""
129-
if self.transport is None or not self._owns_transport:
157+
if not self._owns_transport:
158+
return
159+
target = self._sse_transport or self.transport
160+
if target is None:
130161
return
131162
try:
132-
self.transport.close()
163+
target.close()
133164
except Exception:
134165
pass
135166

0 commit comments

Comments
 (0)