diff --git a/.env.example b/.env.example index ce212be4..d76831f0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/examples/sandbox_idempotency_retry.py b/examples/sandbox_idempotency_retry.py new file mode 100644 index 00000000..ae84eb29 --- /dev/null +++ b/examples/sandbox_idempotency_retry.py @@ -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('沙箱已清理') diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index fb45f194..af140f43 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- +import numbers import os +import random import time +import uuid import requests @@ -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): @@ -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'} @@ -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, @@ -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: @@ -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): + 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) @@ -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 @@ -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 diff --git a/qiniu/services/sandbox/errors.py b/qiniu/services/sandbox/errors.py index 1635fabe..c2b85ce7 100644 --- a/qiniu/services/sandbox/errors.py +++ b/qiniu/services/sandbox/errors.py @@ -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) diff --git a/qiniu/services/sandbox/sandbox.py b/qiniu/services/sandbox/sandbox.py index 754257c0..6ade2857 100644 --- a/qiniu/services/sandbox/sandbox.py +++ b/qiniu/services/sandbox/sandbox.py @@ -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) @@ -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) diff --git a/tests/cases/test_services/test_sandbox/test_client.py b/tests/cases/test_services/test_sandbox/test_client.py index f291f1e9..03d9519f 100644 --- a/tests/cases/test_services/test_sandbox/test_client.py +++ b/tests/cases/test_services/test_sandbox/test_client.py @@ -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: @@ -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()) diff --git a/tests/cases/test_services/test_sandbox/test_integration.py b/tests/cases/test_services/test_sandbox/test_integration.py index 059737e1..739905c4 100644 --- a/tests/cases/test_services/test_sandbox/test_integration.py +++ b/tests/cases/test_services/test_sandbox/test_integration.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import os import time - import pytest from qiniu.services.sandbox import (