Skip to content
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
QINIU_SANDBOX_API_KEY=

# Optional custom endpoint.
# QINIU_SANDBOX_ENDPOINT is the preferred name; QINIU_SANDBOX_API_URL and
# E2B_API_URL are also accepted by the SDK for compatibility.
QINIU_SANDBOX_ENDPOINT=

# Optional template alias or ID. Defaults to base.
Expand All @@ -33,3 +31,6 @@ QINIU_SANDBOX_KODO_PREFIX=
# Optional request injection examples.
QINIU_SANDBOX_HTTP_INJECTION_TOKEN=real_token
QINIU_SANDBOX_OPENAI_API_KEY=

# Optional max retry count for sandbox create/connect (default: 5, 0 to disable).
SANDBOX_RETRY_MAX=5
28 changes: 28 additions & 0 deletions examples/sandbox_idempotency_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""幂等重试示例:同一幂等键连调两次 Create,验证返回同一沙箱。"""
import os
import sys
import time

from qiniu.services.sandbox import Sandbox

API_KEY = os.getenv('QINIU_SANDBOX_API_KEY') or os.getenv('QINIU_API_KEY') or os.getenv('E2B_API_KEY')
if not API_KEY:
print('请设置 QINIU_SANDBOX_API_KEY 环境变量')
sys.exit(1)

ENDPOINT = os.getenv('QINIU_SANDBOX_ENDPOINT') or os.getenv('QINIU_SANDBOX_API_URL')

sandbox = Sandbox.create(
template='base',
timeout=300,
endpoint=ENDPOINT,
api_key=API_KEY,
idempotency_key='sdk-example-{}'.format(int(time.time())),
)
print('沙箱创建成功: {}'.format(sandbox.sandbox_id))
print('幂等键: {}'.format(sandbox.info.get('idempotencyKey', '(auto-generated)')))
Comment thread
DROWNING2003 marked this conversation as resolved.
Outdated

sandbox.kill()
print('沙箱已清理')
67 changes: 56 additions & 11 deletions qiniu/services/sandbox/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import os
import time
import uuid

import requests

Expand Down Expand Up @@ -187,7 +188,8 @@ def _sandbox_api_key_from_env():
class SandboxClient(object):
def __init__(self, endpoint=None, api_url=None, api_key=None,
access_token=None, mac=None, access_key=None,
secret_key=None, session=None, timeout=None, **opts):
secret_key=None, session=None, timeout=None,
max_retries=None, **opts):
access_key = access_key or os.getenv('QINIU_SANDBOX_ACCESS_KEY')
secret_key = secret_key or os.getenv('QINIU_SANDBOX_SECRET_KEY')
if (access_key and not secret_key) or (secret_key and not access_key):
Expand All @@ -202,6 +204,11 @@ def __init__(self, endpoint=None, api_url=None, api_key=None,
self.mac = QiniuMacAuth(access_key, secret_key)
self.session = session or requests.Session()
self.timeout = timeout if timeout is not None else 30
if max_retries is not None:
self.max_retries = max_retries
Comment thread
DROWNING2003 marked this conversation as resolved.
Outdated
else:
env_val = os.getenv('SANDBOX_RETRY_MAX')
self.max_retries = int(env_val) if env_val and env_val.isdigit() else 5

def _headers(self, auth_type=None):
headers = {'Content-Type': 'application/json'}
Expand Down Expand Up @@ -233,10 +240,12 @@ def _auth(self, auth_type=None):
return None

def _request(self, method, path, params=None, body=_UNSET,
auth_type=None, empty=False):
auth_type=None, empty=False, extra_headers=None):
url = self.endpoint + path
data = None if body is _UNSET else json_dumps(body)
headers = self._headers(auth_type)
if extra_headers:
headers.update(extra_headers)
auth = self._auth(auth_type)
request = requests.Request(
method=method,
Expand Down Expand Up @@ -279,6 +288,33 @@ def _request(self, method, path, params=None, body=_UNSET,
return None
return parse_json_response(response)

def _is_retryable(self, err):
if isinstance(err, SandboxError):
Comment thread
DROWNING2003 marked this conversation as resolved.
sc = getattr(err, 'status_code', None) or 0
if sc == 408:
return True
if sc >= 500 and sc != 501:
return True
return False
msg = str(err).lower()
for pattern in (
'connection refused', 'connection reset', 'broken pipe',
'no such host', 'unexpected eof', 'use of closed',
'timed out', 'timeout',
):
if pattern in msg:
return True
return False

def _retry_call(self, fn):
for attempt in range(self.max_retries + 1):
try:
return fn()
except (SandboxError, requests.RequestException) as err:
if attempt < self.max_retries and self._is_retryable(err):
continue
raise

def list_sandboxes(self, **opts):
return self._request('GET', '/sandboxes', params=opts)

Expand All @@ -299,11 +335,18 @@ def create_sandbox(self, template=None, **opts):
_has_kodo_resource(body.get('resources')) or
_has_saved_injection_rule(body.get('injections'))
) else None
return self._request(
'POST',
'/sandboxes',
body=body,
auth_type=auth_type)
idempotency_key = opts.get('idempotency_key') or opts.get('idempotencyKey')
if not idempotency_key:
idempotency_key = str(uuid.uuid4())
return self._retry_call(
lambda: self._request(
'POST',
'/sandboxes',
body=body,
auth_type=auth_type,
extra_headers={'Idempotency-Key': idempotency_key},
)
)

createSandbox = create_sandbox
create = create_sandbox
Expand Down Expand Up @@ -353,10 +396,12 @@ def resume_sandbox(self, sandbox_id, **opts):

def connect_sandbox(self, sandbox_id, timeout=15):
_require_sandbox_id(sandbox_id)
return self._request(
'POST',
'/sandboxes/{0}/connect'.format(encode_path(sandbox_id)),
body={'timeout': timeout},
return self._retry_call(
lambda: self._request(
'POST',
'/sandboxes/{0}/connect'.format(encode_path(sandbox_id)),
body={'timeout': timeout},
)
)

connectSandbox = connect_sandbox
Expand Down
3 changes: 2 additions & 1 deletion qiniu/services/sandbox/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def __init__(self, client=None, info=None, sandbox_id=None, sandboxID=None,
def create(cls, template=None, client=None, timeout=None, metadata=None,
envs=None, secure=True, allow_internet_access=True, mcp=None,
network=None, lifecycle=None, resources=None, injections=None,
**opts):
idempotency_key=None, **opts):
client_opts = {}
for key in ('endpoint', 'api_url', 'api_key', 'access_token',
'mac', 'access_key', 'secret_key', 'session'):
Expand All @@ -158,6 +158,7 @@ def create(cls, template=None, client=None, timeout=None, metadata=None,
lifecycle=lifecycle,
resources=resources,
injections=injections,
idempotency_key=idempotency_key,
**opts
)
sandbox = cls(client=client, info=info)
Expand Down
32 changes: 32 additions & 0 deletions tests/cases/test_services/test_sandbox/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,35 @@ def test_list_and_connect_existing_sandbox():

connected = Sandbox.connect(page[0].sandbox_id, client=client, timeout=60)
assert connected.sandbox_id == page[0].sandbox_id


def test_create_retry_with_git_clone():
"""挂载大仓库时 clone 可能超时返回 408,幂等键保证重试不会重复创建沙箱。"""
client = integration_client()
repo_url = os.getenv('GITHUB_REPO_URL')
Comment thread
DROWNING2003 marked this conversation as resolved.
Outdated
token = os.getenv('GITHUB_TOKEN')
if not repo_url or not token:
pytest.skip('GITHUB_REPO_URL / GITHUB_TOKEN 未设置')

print('\n仓库: {}, 幂等键: sdk-retry-git-{}'.format(
repo_url, int(time.time())))

from qiniu.services.sandbox.resources import GitRepositoryResource

try:
sandbox = Sandbox.create(
os.getenv('QINIU_SANDBOX_TEMPLATE', 'base'),
timeout=300,
resources=[GitRepositoryResource(
url=repo_url,
mount_path='/repo',
repository_type='github_repository',
authorization_token=token,
)],
idempotency_key='sdk-retry-git-{}'.format(int(time.time())),
client=client,
)
print('沙箱创建成功: {}'.format(sandbox.sandbox_id))
sandbox.kill()
except SandboxError as err:
Comment thread
DROWNING2003 marked this conversation as resolved.
Outdated
print('Create 失败(clone 超时等可重试错误): {}'.format(err))
Comment thread
DROWNING2003 marked this conversation as resolved.
Outdated