Skip to content
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,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
41 changes: 41 additions & 0 deletions examples/sandbox_idempotency_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/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')

idempotency_key = 'sdk-example-{}'.format(int(time.time()))
print('幂等键: {}'.format(idempotency_key))

first_sandbox = Sandbox.create(
template='base', timeout=300, endpoint=ENDPOINT, api_key=API_KEY,
idempotency_key=idempotency_key,
)
second_sandbox = None
try:
print('第一次创建: {}'.format(first_sandbox.sandbox_id))
second_sandbox = Sandbox.create(
template='base', timeout=300, endpoint=ENDPOINT, api_key=API_KEY,
idempotency_key=idempotency_key,
)
print('第二次创建: {}'.format(second_sandbox.sandbox_id))
if first_sandbox.sandbox_id != second_sandbox.sandbox_id:
raise RuntimeError(
'幂等重试验证失败:两次创建返回不同沙箱: {} vs {}'.format(
first_sandbox.sandbox_id, second_sandbox.sandbox_id))
print('幂等重试验证通过:两次创建返回同一沙箱')
finally:
if second_sandbox is not None and second_sandbox.sandbox_id != first_sandbox.sandbox_id:
second_sandbox.kill()
first_sandbox.kill()
print('沙箱已清理')
92 changes: 80 additions & 12 deletions qiniu/services/sandbox/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
import numbers
import os
import random
import time
import uuid

import requests

Expand Down Expand Up @@ -184,10 +187,33 @@ def _sandbox_api_key_from_env():
)


def _normalize_max_retries(value, source, allow_string=False):
if isinstance(value, bool):
raise SandboxError(
'{0} must be a non-negative integer'.format(source))
if isinstance(value, basestring):
if not allow_string:
raise SandboxError(
'{0} must be a non-negative integer'.format(source))
try:
value = int(value)
except (TypeError, ValueError):
raise SandboxError(
'{0} must be a non-negative integer'.format(source))
elif not isinstance(value, numbers.Integral):
raise SandboxError(
'{0} must be a non-negative integer'.format(source))
if value < 0:
raise SandboxError(
'{0} must be a non-negative integer'.format(source))
return int(value)


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 +228,15 @@ 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 None:
env_val = os.getenv('SANDBOX_RETRY_MAX')
self.max_retries = (
_normalize_max_retries(
env_val, 'SANDBOX_RETRY_MAX', allow_string=True)
if env_val else 5)
else:
self.max_retries = _normalize_max_retries(
max_retries, 'max_retries')

def _headers(self, auth_type=None):
headers = {'Content-Type': 'application/json'}
Expand Down Expand Up @@ -233,10 +268,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 All @@ -250,7 +287,8 @@ def _request(self, method, path, params=None, body=_UNSET,
try:
response = self.session.send(prepared, timeout=self.timeout)
except requests.RequestException as err:
raise SandboxError('Sandbox API request failed: {0}'.format(err))
raise SandboxError(
'Sandbox API request failed: {0}'.format(err), cause=err)
if response.status_code < 200 or response.status_code >= 300:
response_data = None
try:
Expand Down Expand Up @@ -279,6 +317,27 @@ 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)
if sc == 408:
return True
if sc is not None and sc >= 500 and sc != 501:
return True
err = getattr(err, 'cause', None)
return isinstance(err, (requests.ConnectionError, requests.Timeout))

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):
base = min(0.5 * (2 ** attempt), 10)
time.sleep(base + random.random() * base / 2)
continue
raise

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

Expand All @@ -299,11 +358,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 +419,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/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@


class SandboxError(Exception):
def __init__(self, message, response=None, data=None):
def __init__(self, message, response=None, data=None, cause=None):
super(SandboxError, self).__init__(message)
self.response = response
self.data = data
self.cause = cause
self.status_code = getattr(response, 'status_code', None)


Expand Down
6 changes: 4 additions & 2 deletions qiniu/services/sandbox/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,11 @@ 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'):
'mac', 'access_key', 'secret_key', 'session',
'max_retries'):
if key in opts:
client_opts[key] = opts.pop(key)
client = client or SandboxClient(**client_opts)
Expand All @@ -158,6 +159,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
88 changes: 88 additions & 0 deletions tests/cases/test_services/test_sandbox/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
import requests
import qiniu.services.sandbox.client as sandbox_client_module
import qiniu.services.sandbox.sandbox as sandbox_module

try:
Expand Down Expand Up @@ -122,6 +123,93 @@ def test_client_uses_default_endpoint_and_api_key_headers():
}


@pytest.mark.parametrize('status_code', [408, 500])
def test_create_sandbox_retries_retryable_status_and_reuses_idempotency_key(
monkeypatch, status_code):
session = RecordingSession([
ErrorResponse(status_code),
DummyResponse(201, {'sandboxID': 'sbx123'}),
])
monkeypatch.setattr(sandbox_client_module.time, 'sleep', lambda _: None)
client = SandboxClient(
api_key='api-key', session=session, max_retries=1)

result = client.create_sandbox(
template='base', idempotency_key='retry-key')

assert result['sandboxID'] == 'sbx123'
assert len(session.requests) == 2
assert [request.headers['Idempotency-Key'] for request in session.requests] == [
'retry-key', 'retry-key']


@pytest.mark.parametrize(
'error_type', [requests.ConnectionError, requests.exceptions.SSLError])
def test_create_sandbox_retries_transport_error_and_reuses_idempotency_key(
monkeypatch, error_type):
session = RecordingSession([
error_type('[Errno 101] Network is unreachable'),
DummyResponse(201, {'sandboxID': 'sbx123'}),
])
monkeypatch.setattr(sandbox_client_module.time, 'sleep', lambda _: None)
client = SandboxClient(
api_key='api-key', session=session, max_retries=1)

result = client.create_sandbox(
template='base', idempotency_key='retry-key')

assert result['sandboxID'] == 'sbx123'
assert len(session.requests) == 2
assert [request.headers['Idempotency-Key'] for request in session.requests] == [
'retry-key', 'retry-key']


@pytest.mark.parametrize('max_retries', [-1, '1', 1.5, True])
def test_sandbox_client_rejects_invalid_max_retries(max_retries):
with pytest.raises(SandboxError, match='max_retries'):
SandboxClient(
api_key='api-key', session=RecordingSession(),
max_retries=max_retries)


@pytest.mark.parametrize('max_retries', ['-1', 'invalid'])
def test_sandbox_client_rejects_invalid_retry_environment(
monkeypatch, max_retries):
monkeypatch.setenv('SANDBOX_RETRY_MAX', max_retries)

with pytest.raises(SandboxError, match='SANDBOX_RETRY_MAX'):
SandboxClient(api_key='api-key', session=RecordingSession())


def test_sandbox_client_allows_zero_max_retries():
client = SandboxClient(
api_key='api-key', session=RecordingSession(), max_retries=0)

assert client.max_retries == 0


def test_sandbox_create_forwards_max_retries(monkeypatch):
created_clients = []

class CapturingClient(object):
def __init__(self, **opts):
self.max_retries = opts.get('max_retries')
created_clients.append(self)

def create_sandbox(self, *args, **opts):
return {'sandboxID': 'sbx123'}

def get_sandbox(self, sandbox_id):
return {'sandboxID': sandbox_id, 'envdAccessToken': 'token'}

monkeypatch.setattr(sandbox_module, 'SandboxClient', CapturingClient)

sandbox = Sandbox.create('base', max_retries=0)

assert sandbox.sandbox_id == 'sbx123'
assert created_clients[0].max_retries == 0


def test_create_sandbox_rejects_conflicting_option_aliases():
client = SandboxClient(api_key='api-key', session=RecordingSession())

Expand Down
1 change: 0 additions & 1 deletion tests/cases/test_services/test_sandbox/test_integration.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
import os
import time

import pytest

from qiniu.services.sandbox import (
Expand Down
Loading