From b33fc943c04aa0a03f7c42b8205df231c8a37a38 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:23:54 +0800 Subject: [PATCH 1/7] feat(sandbox): add idempotent retry for sandbox creation and connection - Add idempotency key auto-generation (uuid4) with Idempotency-Key header - Add _retryCall method wrapping create/connect with configurable max_retries - Retry on 408/5xx server errors and network errors (timeout, connection refused, etc.) - Configurable via SandboxClient(max_retries=N) or SANDBOX_RETRY_MAX env var - Add Sandbox.create(idempotency_key=...) optional parameter - Add integration test for Git clone timeout retry scenario - Add sandbox_idempotency_retry example - Add SANDBOX_RETRY_MAX to .env.example --- .env.example | 5 +- examples/sandbox_idempotency_retry.py | 28 ++++++++ qiniu/services/sandbox/client.py | 67 ++++++++++++++++--- qiniu/services/sandbox/sandbox.py | 3 +- .../test_sandbox/test_integration.py | 32 +++++++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 examples/sandbox_idempotency_retry.py diff --git a/.env.example b/.env.example index ce212be4..4465d0af 100644 --- a/.env.example +++ b/.env.example @@ -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. @@ -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 diff --git a/examples/sandbox_idempotency_retry.py b/examples/sandbox_idempotency_retry.py new file mode 100644 index 00000000..45fc8fdb --- /dev/null +++ b/examples/sandbox_idempotency_retry.py @@ -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)'))) + +sandbox.kill() +print('沙箱已清理') diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index fb45f194..af6960c2 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os import time +import uuid import requests @@ -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): @@ -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 + 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'} @@ -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, @@ -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): + 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) @@ -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 @@ -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 diff --git a/qiniu/services/sandbox/sandbox.py b/qiniu/services/sandbox/sandbox.py index 754257c0..181da20f 100644 --- a/qiniu/services/sandbox/sandbox.py +++ b/qiniu/services/sandbox/sandbox.py @@ -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'): @@ -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) diff --git a/tests/cases/test_services/test_sandbox/test_integration.py b/tests/cases/test_services/test_sandbox/test_integration.py index 059737e1..ab951190 100644 --- a/tests/cases/test_services/test_sandbox/test_integration.py +++ b/tests/cases/test_services/test_sandbox/test_integration.py @@ -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') + 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: + print('Create 失败(clone 超时等可重试错误): {}'.format(err)) From 87067731483398955e60a6a5b984e2c8db7b19bf Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:43:49 +0800 Subject: [PATCH 2/7] fix(sandbox): add backoff between retries and fail on non-retryable errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add exponential backoff with jitter (500ms → 10s) between retries - Integration test: re-raise non-retryable 4xx errors instead of logging --- qiniu/services/sandbox/client.py | 3 +++ tests/cases/test_services/test_sandbox/test_integration.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index af6960c2..bf139721 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +import random import time import uuid @@ -312,6 +313,8 @@ def _retry_call(self, fn): 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 diff --git a/tests/cases/test_services/test_sandbox/test_integration.py b/tests/cases/test_services/test_sandbox/test_integration.py index ab951190..0e11aabf 100644 --- a/tests/cases/test_services/test_sandbox/test_integration.py +++ b/tests/cases/test_services/test_sandbox/test_integration.py @@ -330,4 +330,7 @@ def test_create_retry_with_git_clone(): print('沙箱创建成功: {}'.format(sandbox.sandbox_id)) sandbox.kill() except SandboxError as err: + sc = getattr(err, 'status_code', None) or 0 + if sc >= 400 and sc < 500: + raise print('Create 失败(clone 超时等可重试错误): {}'.format(err)) From dc8bd76d3f4be57ce55fc8b8cff7c6775976e2ae Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:46:34 +0800 Subject: [PATCH 3/7] fix(sandbox): retry network errors wrapped as SandboxError - _is_retryable now checks SandboxError messages when status_code is 0 (wrapped network errors like timeouts, connection refused) - Extract _is_retryable_message for reuse --- qiniu/services/sandbox/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index bf139721..87d3dbdf 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -296,8 +296,13 @@ def _is_retryable(self, err): return True if sc >= 500 and sc != 501: return True + if sc == 0: + return self._is_retryable_message(str(err)) return False - msg = str(err).lower() + return self._is_retryable_message(str(err)) + + def _is_retryable_message(self, msg): + msg = msg.lower() for pattern in ( 'connection refused', 'connection reset', 'broken pipe', 'no such host', 'unexpected eof', 'use of closed', From 0da1669f47c2e16a9ce6f242624c1f96a2d18740 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:55:32 +0800 Subject: [PATCH 4/7] fix(sandbox): add 'failed to resolve' to retryable patterns, tighten integration test - Add 'failed to resolve' DNS pattern to retryable message matching - Integration test: raise on all 4xx/5xx except 408 instead of only 4xx --- qiniu/services/sandbox/client.py | 2 +- tests/cases/test_services/test_sandbox/test_integration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index 87d3dbdf..777ff3a0 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -306,7 +306,7 @@ def _is_retryable_message(self, msg): for pattern in ( 'connection refused', 'connection reset', 'broken pipe', 'no such host', 'unexpected eof', 'use of closed', - 'timed out', 'timeout', + 'timed out', 'timeout', 'failed to resolve', ): if pattern in msg: return True diff --git a/tests/cases/test_services/test_sandbox/test_integration.py b/tests/cases/test_services/test_sandbox/test_integration.py index 0e11aabf..2ed9d749 100644 --- a/tests/cases/test_services/test_sandbox/test_integration.py +++ b/tests/cases/test_services/test_sandbox/test_integration.py @@ -331,6 +331,6 @@ def test_create_retry_with_git_clone(): sandbox.kill() except SandboxError as err: sc = getattr(err, 'status_code', None) or 0 - if sc >= 400 and sc < 500: + if sc >= 400 and sc != 408: raise print('Create 失败(clone 超时等可重试错误): {}'.format(err)) From 14544090f014eb894e160739c56b5047ab9b90e5 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 16:06:08 +0800 Subject: [PATCH 5/7] chore: restore compatibility comment for endpoint env vars in .env.example --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index 4465d0af..d76831f0 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ 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. From 5ae06a8ec7eb3c35e988547eaa9618c1d6c8ff95 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 17:04:09 +0800 Subject: [PATCH 6/7] fix(sandbox): validate max_retries, propagate cause in SandboxError, and add unit tests _normalize_max_retries validates max_retries as non-negative integer. _is_retryable inspects err.cause via ConnectionError/Timeout instead of string matching. SandboxError carries cause from the underlying request exception. Add 6 unit tests for retry/validation. Use GIT_REPO_URL. --- qiniu/services/sandbox/client.py | 59 ++++++++++------- qiniu/services/sandbox/errors.py | 3 +- .../test_services/test_sandbox/test_client.py | 66 +++++++++++++++++++ .../test_sandbox/test_integration.py | 4 +- 4 files changed, 107 insertions(+), 25 deletions(-) diff --git a/qiniu/services/sandbox/client.py b/qiniu/services/sandbox/client.py index 777ff3a0..af140f43 100644 --- a/qiniu/services/sandbox/client.py +++ b/qiniu/services/sandbox/client.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import numbers import os import random import time @@ -186,6 +187,28 @@ 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, @@ -205,11 +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 not None: - self.max_retries = max_retries - else: + if max_retries is None: env_val = os.getenv('SANDBOX_RETRY_MAX') - self.max_retries = int(env_val) if env_val and env_val.isdigit() else 5 + 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'} @@ -260,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: @@ -291,26 +319,13 @@ def _request(self, method, path, params=None, body=_UNSET, def _is_retryable(self, err): if isinstance(err, SandboxError): - sc = getattr(err, 'status_code', None) or 0 + sc = getattr(err, 'status_code', None) if sc == 408: return True - if sc >= 500 and sc != 501: - return True - if sc == 0: - return self._is_retryable_message(str(err)) - return False - return self._is_retryable_message(str(err)) - - def _is_retryable_message(self, msg): - msg = msg.lower() - for pattern in ( - 'connection refused', 'connection reset', 'broken pipe', - 'no such host', 'unexpected eof', 'use of closed', - 'timed out', 'timeout', 'failed to resolve', - ): - if pattern in msg: + if sc is not None and sc >= 500 and sc != 501: return True - return False + 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): 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/tests/cases/test_services/test_sandbox/test_client.py b/tests/cases/test_services/test_sandbox/test_client.py index f291f1e9..d5690f7c 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,71 @@ 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_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 2ed9d749..ee1bd856 100644 --- a/tests/cases/test_services/test_sandbox/test_integration.py +++ b/tests/cases/test_services/test_sandbox/test_integration.py @@ -304,10 +304,10 @@ def test_list_and_connect_existing_sandbox(): def test_create_retry_with_git_clone(): """挂载大仓库时 clone 可能超时返回 408,幂等键保证重试不会重复创建沙箱。""" client = integration_client() - repo_url = os.getenv('GITHUB_REPO_URL') + repo_url = os.getenv('GIT_REPO_URL') token = os.getenv('GITHUB_TOKEN') if not repo_url or not token: - pytest.skip('GITHUB_REPO_URL / GITHUB_TOKEN 未设置') + pytest.skip('GIT_REPO_URL / GITHUB_TOKEN 未设置') print('\n仓库: {}, 幂等键: sdk-retry-git-{}'.format( repo_url, int(time.time()))) From bbee45384f9f2205fb2e88d0321511c31391b734 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 17:48:27 +0800 Subject: [PATCH 7/7] fix(sandbox): forward max_retries in Sandbox.create and improve idempotency example Sandbox.create now passes max_retries to SandboxClient internally. Example verifies idempotency with dual creation and proper cleanup. Remove test_create_retry_with_git_clone integration test. --- examples/sandbox_idempotency_retry.py | 35 ++++++++++++------ qiniu/services/sandbox/sandbox.py | 3 +- .../test_services/test_sandbox/test_client.py | 22 ++++++++++++ .../test_sandbox/test_integration.py | 36 ------------------- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/examples/sandbox_idempotency_retry.py b/examples/sandbox_idempotency_retry.py index 45fc8fdb..ae84eb29 100644 --- a/examples/sandbox_idempotency_retry.py +++ b/examples/sandbox_idempotency_retry.py @@ -14,15 +14,28 @@ 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)'))) +idempotency_key = 'sdk-example-{}'.format(int(time.time())) +print('幂等键: {}'.format(idempotency_key)) -sandbox.kill() -print('沙箱已清理') +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/sandbox.py b/qiniu/services/sandbox/sandbox.py index 181da20f..6ade2857 100644 --- a/qiniu/services/sandbox/sandbox.py +++ b/qiniu/services/sandbox/sandbox.py @@ -142,7 +142,8 @@ def create(cls, template=None, client=None, timeout=None, metadata=None, 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) diff --git a/tests/cases/test_services/test_sandbox/test_client.py b/tests/cases/test_services/test_sandbox/test_client.py index d5690f7c..03d9519f 100644 --- a/tests/cases/test_services/test_sandbox/test_client.py +++ b/tests/cases/test_services/test_sandbox/test_client.py @@ -188,6 +188,28 @@ def test_sandbox_client_allows_zero_max_retries(): 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 ee1bd856..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 ( @@ -299,38 +298,3 @@ 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('GIT_REPO_URL') - token = os.getenv('GITHUB_TOKEN') - if not repo_url or not token: - pytest.skip('GIT_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: - sc = getattr(err, 'status_code', None) or 0 - if sc >= 400 and sc != 408: - raise - print('Create 失败(clone 超时等可重试错误): {}'.format(err))