From 50eaa85c2af3847361d759f94a0e20e9fc4c6fb0 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:23:57 +0800 Subject: [PATCH 1/6] feat(sandbox): add idempotent retry for sandbox creation and connection - Add idempotency key auto-generation (crypto.randomUUID) with Idempotency-Key header - Add _retryCall method wrapping create/connect with configurable maxRetries - Retry on 408/5xx server errors and network errors (timeout, connection refused, etc.) - Configurable via maxRetries option or SANDBOX_RETRY_MAX env var - Add Sandbox.create({ idempotencyKey }) 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 | 3 ++ examples/sandbox_idempotency_retry.js | 28 ++++++++++++++ qiniu/sandbox/client.js | 56 ++++++++++++++++++++++++--- test/sandbox_integration.test.js | 30 ++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 examples/sandbox_idempotency_retry.js diff --git a/.env.example b/.env.example index 693f03c..2fcaf78 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,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.js b/examples/sandbox_idempotency_retry.js new file mode 100644 index 0000000..b7bb460 --- /dev/null +++ b/examples/sandbox_idempotency_retry.js @@ -0,0 +1,28 @@ +/** + * 幂等重试示例:同一幂等键连调两次 Create,验证返回同一沙箱。 + */ +const qiniu = require('../'); + +const apiKey = process.env.QINIU_SANDBOX_API_KEY; +if (!apiKey) { + console.error('请设置 QINIU_SANDBOX_API_KEY 环境变量'); + process.exit(1); +} + +const opts = { + templateID: 'base', + timeout: 300, + endpoint: process.env.QINIU_SANDBOX_ENDPOINT, + apiKey, + idempotencyKey: `sdk-example-${Math.floor(Date.now() / 1000)}` +}; + +console.log('幂等键:', opts.idempotencyKey); + +qiniu.Sandbox.create(opts).then(sandbox => { + console.log('沙箱创建成功:', sandbox.sandboxId); + return sandbox.kill().then(() => console.log('沙箱已清理')); +}).catch(err => { + console.error('失败:', err.message); + process.exit(1); +}); diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 7955f6b..5a49bbe 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -1,5 +1,6 @@ const http = require('http'); const https = require('https'); +const crypto = require('crypto'); const { HttpClient } = require('../httpc/client'); const { QiniuAuthMiddleware } = require('../httpc/middleware/qiniuAuth'); @@ -15,6 +16,10 @@ const { timeoutSecondsFromOptions } = require('./util'); +function generateIdempotencyKey () { + return crypto.randomUUID(); +} + function normalizeSandboxCreateOptions (opts) { opts = opts || {}; const body = {}; @@ -143,6 +148,7 @@ function normalizeClientOptions (opts) { } function SandboxClient (opts) { + opts = opts || {}; const normalized = normalizeClientOptions(opts); this.endpoint = normalized.endpoint; this.apiKey = normalized.apiKey; @@ -153,6 +159,12 @@ function SandboxClient (opts) { httpAgent: normalized.httpAgent, httpsAgent: normalized.httpsAgent }); + if (opts.maxRetries !== undefined && opts.maxRetries !== null) { + this.maxRetries = opts.maxRetries; + } else { + const envVal = process.env.SANDBOX_RETRY_MAX; + this.maxRetries = envVal && /^\d+$/.test(envVal) ? parseInt(envVal, 10) : 5; + } } SandboxClient.prototype._headers = function (authType) { @@ -199,6 +211,9 @@ SandboxClient.prototype._request = function (method, path, options) { const body = options.body; const hasBody = body !== undefined && body !== null; const headers = this._headers(options.authType); + if (options.headers) { + Object.assign(headers, options.headers); + } if (!hasBody && (method === 'GET' || method === 'HEAD')) { delete headers['Content-Type']; } @@ -251,6 +266,33 @@ SandboxClient.prototype._handleResponse = function (wrapper, empty) { throw new SandboxError(message, wrapper.resp, data); }; +SandboxClient.prototype._isRetryable = function (err) { + if (err instanceof SandboxError) { + const sc = (err.response && err.response.statusCode) || + (err.resp && err.resp.statusCode) || 0; + if (sc === 408) return true; + if (sc >= 500 && sc !== 501) return true; + return false; + } + const msg = String(err.message || err).toLowerCase(); + return ['connection refused', 'connection reset', 'broken pipe', + 'no such host', 'unexpected eof', 'use of closed', + 'timed out', 'timeout'].some(p => msg.includes(p)); +}; + +SandboxClient.prototype._retryCall = function (fn) { + const self = this; + function attempt (n) { + return fn().catch(err => { + if (n < self.maxRetries && self._isRetryable(err)) { + return attempt(n + 1); + } + throw err; + }); + } + return attempt(0); +}; + SandboxClient.prototype.listSandboxes = function (opts) { return this._request('GET', appendQuery('/sandboxes', opts)); }; @@ -260,11 +302,15 @@ SandboxClient.prototype.listSandboxesV2 = function (opts) { }; SandboxClient.prototype.createSandbox = function (opts) { + opts = opts || {}; const body = normalizeSandboxCreateOptions(opts); - return this._request('POST', '/sandboxes', { + const idempotencyKey = opts.idempotencyKey || opts.idempotency_key || generateIdempotencyKey(); + const headers = { 'Idempotency-Key': idempotencyKey }; + return this._retryCall(() => this._request('POST', '/sandboxes', { authType: hasKodoResource(body) ? 'qiniu' : undefined, - body - }); + body, + headers + })); }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { @@ -354,11 +400,11 @@ SandboxClient.prototype.resumeSandbox = function (sandboxID, opts) { SandboxClient.prototype.connectSandbox = function (sandboxID, opts) { const timeout = timeoutSecondsFromOptions(opts); - return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/connect`, { + return this._retryCall(() => this._request('POST', `/sandboxes/${encodePath(sandboxID)}/connect`, { body: { timeout: timeout === undefined ? 15 : timeout } - }); + })); }; SandboxClient.prototype.updateSandboxTimeout = function (sandboxID, timeoutOrOpts) { diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js index 12a7106..6d185e1 100644 --- a/test/sandbox_integration.test.js +++ b/test/sandbox_integration.test.js @@ -451,6 +451,36 @@ describeIntegration('sandbox integration', function () { integrationLog('default template integration completed', templates.length); }); }); + + it('retries sandbox creation with git clone timeout', function () { + const repoURL = process.env.GITHUB_REPO_URL; + const token = process.env.GITHUB_TOKEN; + if (!repoURL || !token) { + console.log('skip: GITHUB_REPO_URL / GITHUB_TOKEN not set'); + return Promise.resolve(); + } + + const client = sandboxClient(); + const idempotencyKey = `sdk-retry-git-${Math.floor(Date.now() / 1000)}`; + console.log(`repo: ${repoURL}, idempotencyKey: ${idempotencyKey}`); + + return client.createSandbox({ + template: config.template, + timeout: 300, + resources: [{ + type: 'github_repository', + url: repoURL, + mount_path: '/repo', + authorization_token: token + }], + idempotencyKey + }).then(info => { + console.log('sandbox created:', info.sandboxID); + return client.deleteSandbox(info.sandboxID); + }).catch(err => { + console.log('create failed (retryable scenario):', err.message); + }); + }); }); if (!config.apiKey) { From a99db90cd5e853e31dc0d0811ff12e9dc7e48439 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:43:51 +0800 Subject: [PATCH 2/6] 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-throw non-retryable 4xx errors instead of logging --- qiniu/sandbox/client.js | 5 ++++- test/sandbox_integration.test.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 5a49bbe..6d6f984 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -285,7 +285,10 @@ SandboxClient.prototype._retryCall = function (fn) { function attempt (n) { return fn().catch(err => { if (n < self.maxRetries && self._isRetryable(err)) { - return attempt(n + 1); + const base = Math.min(500 * Math.pow(2, n), 10000); + const jitter = Math.random() * base / 2; + return new Promise(resolve => setTimeout(resolve, base + jitter)) + .then(() => attempt(n + 1)); } throw err; }); diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js index 6d185e1..7004847 100644 --- a/test/sandbox_integration.test.js +++ b/test/sandbox_integration.test.js @@ -478,6 +478,11 @@ describeIntegration('sandbox integration', function () { console.log('sandbox created:', info.sandboxID); return client.deleteSandbox(info.sandboxID); }).catch(err => { + const sc = (err.response && err.response.statusCode) || + (err.resp && err.resp.statusCode) || 0; + if (sc >= 400 && sc < 500) { + throw err; + } console.log('create failed (retryable scenario):', err.message); }); }); From 57c3a6342c1427301036051a5f37782921b92d00 Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:46:35 +0800 Subject: [PATCH 3/6] fix(sandbox): address review feedback - Replace crypto.randomUUID() with crypto.randomBytes(16) for Node 6+ compat - Add maxRetries to sandboxClientOptions() so Sandbox.create() respects it - Add idempotencyKey to TypeScript SandboxCreateOptions - Add maxRetries to TypeScript SandboxClientOptions - Fix example to call Create twice with same idempotency key --- examples/sandbox_idempotency_retry.js | 29 +++++++++++++++++++-------- index.d.ts | 2 ++ qiniu/sandbox/client.js | 2 +- qiniu/sandbox/sandbox.js | 3 +++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/examples/sandbox_idempotency_retry.js b/examples/sandbox_idempotency_retry.js index b7bb460..1881407 100644 --- a/examples/sandbox_idempotency_retry.js +++ b/examples/sandbox_idempotency_retry.js @@ -1,5 +1,5 @@ /** - * 幂等重试示例:同一幂等键连调两次 Create,验证返回同一沙箱。 + * 幂等重试示例:同一幂等键连续两次 Create,验证返回同一沙箱。 */ const qiniu = require('../'); @@ -9,19 +9,32 @@ if (!apiKey) { process.exit(1); } -const opts = { +const idempotencyKey = `sdk-example-${Math.floor(Date.now() / 1000)}`; +console.log('幂等键:', idempotencyKey); + +const createOpts = { templateID: 'base', timeout: 300, endpoint: process.env.QINIU_SANDBOX_ENDPOINT, - apiKey, - idempotencyKey: `sdk-example-${Math.floor(Date.now() / 1000)}` + apiKey }; -console.log('幂等键:', opts.idempotencyKey); +Promise.all([ + qiniu.Sandbox.create(Object.assign({}, createOpts, { idempotencyKey })), + qiniu.Sandbox.create(Object.assign({}, createOpts, { idempotencyKey })) +]).then(([sb1, sb2]) => { + console.log('第一次创建:', sb1.sandboxId); + console.log('第二次创建:', sb2.sandboxId); + + if (sb1.sandboxId === sb2.sandboxId) { + console.log('\n幂等重试验证通过:两次创建返回同一沙箱'); + } else { + console.log('\nWARNING: 两次创建返回不同沙箱'); + } -qiniu.Sandbox.create(opts).then(sandbox => { - console.log('沙箱创建成功:', sandbox.sandboxId); - return sandbox.kill().then(() => console.log('沙箱已清理')); + return sb1.kill().then(() => { + console.log('沙箱已清理'); + }); }).catch(err => { console.error('失败:', err.message); process.exit(1); diff --git a/index.d.ts b/index.d.ts index d3303c5..f1fd4c7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -51,6 +51,7 @@ export declare namespace sandbox { timeout?: number | number[]; httpAgent?: HttpAgent; httpsAgent?: HttpsAgent; + maxRetries?: number; } interface GitRepositoryResource { @@ -190,6 +191,7 @@ export declare namespace sandbox { injections?: SandboxInjection[]; resources?: SandboxResource[]; client?: SandboxClient; + idempotencyKey?: string; } interface SandboxConnectOptions extends SandboxClientOptions { diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 6d6f984..c6119f5 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -17,7 +17,7 @@ const { } = require('./util'); function generateIdempotencyKey () { - return crypto.randomUUID(); + return crypto.randomBytes(16).toString('hex'); } function normalizeSandboxCreateOptions (opts) { diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 00d26cf..85e263d 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -152,6 +152,9 @@ function sandboxClientOptions (opts) { if (opts.requestTimeoutMs !== undefined) { clientOpts.timeout = opts.requestTimeoutMs; } + if (opts.maxRetries !== undefined) { + clientOpts.maxRetries = opts.maxRetries; + } return clientOpts; } From 4bee3593bd497d8f771b866a43c4937ca369109b Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 15:55:33 +0800 Subject: [PATCH 4/6] fix(sandbox): kill sb2 in example when idempotency returns different sandboxes Previously sb1 was killed but sb2 leaked if the two Create calls returned different sandbox IDs. --- examples/sandbox_idempotency_retry.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/sandbox_idempotency_retry.js b/examples/sandbox_idempotency_retry.js index 1881407..dc094cd 100644 --- a/examples/sandbox_idempotency_retry.js +++ b/examples/sandbox_idempotency_retry.js @@ -26,13 +26,17 @@ Promise.all([ console.log('第一次创建:', sb1.sandboxId); console.log('第二次创建:', sb2.sandboxId); - if (sb1.sandboxId === sb2.sandboxId) { + const sameSandbox = sb1.sandboxId === sb2.sandboxId; + if (sameSandbox) { console.log('\n幂等重试验证通过:两次创建返回同一沙箱'); } else { console.log('\nWARNING: 两次创建返回不同沙箱'); } - return sb1.kill().then(() => { + const cleanup = sameSandbox + ? sb1.kill() + : Promise.all([sb1.kill(), sb2.kill()]); + return cleanup.then(() => { console.log('沙箱已清理'); }); }).catch(err => { From 88fdad03ec7aca0fb2371b609efeb5041238f82d Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 17:04:07 +0800 Subject: [PATCH 5/6] fix(sandbox): recognize Node.js error codes in _isRetryable and keep idempotency key stable Check err.code for standard Node.js network error codes (ECONNREFUSED, ECONNRESET, etc.) in addition to message-based detection. Add unit tests for retry behavior and idempotency key stability across retries. Use GIT_REPO_URL in integration test. --- qiniu/sandbox/client.js | 8 +++++ test/sandbox_client.test.js | 56 ++++++++++++++++++++++++++++++++ test/sandbox_integration.test.js | 11 ++----- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index c6119f5..4bffd76 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -20,6 +20,11 @@ function generateIdempotencyKey () { return crypto.randomBytes(16).toString('hex'); } +const retryableNetworkErrorCodes = [ + 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', + 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE' +]; + function normalizeSandboxCreateOptions (opts) { opts = opts || {}; const body = {}; @@ -274,6 +279,9 @@ SandboxClient.prototype._isRetryable = function (err) { if (sc >= 500 && sc !== 501) return true; return false; } + if (err && retryableNetworkErrorCodes.indexOf(err.code) >= 0) { + return true; + } const msg = String(err.message || err).toLowerCase(); return ['connection refused', 'connection reset', 'broken pipe', 'no such host', 'unexpected eof', 'use of closed', diff --git a/test/sandbox_client.test.js b/test/sandbox_client.test.js index f2db9f2..359626d 100644 --- a/test/sandbox_client.test.js +++ b/test/sandbox_client.test.js @@ -7,6 +7,62 @@ const { } = require('./sandbox_helpers'); describe('test sandbox client module', function () { + it('retries retryable responses and keeps the idempotency key stable', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + maxRetries: 1 + }); + const requests = []; + client.httpClient.sendRequest = request => { + requests.push(request); + if (requests.length === 1) { + return Promise.resolve({ + ok: () => false, + resp: { statusCode: 408 }, + data: { message: 'request timeout' } + }); + } + return Promise.resolve({ + ok: () => true, + data: { sandboxID: 'sbx_retry' } + }); + }; + const originalSetTimeout = global.setTimeout; + global.setTimeout = callback => { + callback(); + return null; + }; + + const restoreSetTimeout = () => { + global.setTimeout = originalSetTimeout; + }; + return client.createSandbox({ + template: 'base', + idempotencyKey: 'retry-key' + }).then(info => { + restoreSetTimeout(); + info.sandboxID.should.eql('sbx_retry'); + requests.length.should.eql(2); + requests.map(request => request.urllibOptions.headers['Idempotency-Key']) + .should.eql(['retry-key', 'retry-key']); + }, err => { + restoreSetTimeout(); + throw err; + }); + }); + + it('recognizes Node.js network error codes as retryable', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT'].forEach(code => { + client._isRetryable({ code }).should.eql(true); + }); + }); + it('creates sandbox with E2B compatible options and API key auth', function () { return startServer((req, res) => { res.statusCode = 201; diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js index 7004847..54fe3c8 100644 --- a/test/sandbox_integration.test.js +++ b/test/sandbox_integration.test.js @@ -453,10 +453,10 @@ describeIntegration('sandbox integration', function () { }); it('retries sandbox creation with git clone timeout', function () { - const repoURL = process.env.GITHUB_REPO_URL; + const repoURL = process.env.GIT_REPO_URL; const token = process.env.GITHUB_TOKEN; if (!repoURL || !token) { - console.log('skip: GITHUB_REPO_URL / GITHUB_TOKEN not set'); + console.log('skip: GIT_REPO_URL / GITHUB_TOKEN not set'); return Promise.resolve(); } @@ -477,13 +477,6 @@ describeIntegration('sandbox integration', function () { }).then(info => { console.log('sandbox created:', info.sandboxID); return client.deleteSandbox(info.sandboxID); - }).catch(err => { - const sc = (err.response && err.response.statusCode) || - (err.resp && err.resp.statusCode) || 0; - if (sc >= 400 && sc < 500) { - throw err; - } - console.log('create failed (retryable scenario):', err.message); }); }); }); From 7cd2a54af1f342280444b45d6432003939aa44ca Mon Sep 17 00:00:00 2001 From: DROWNING2003 Date: Wed, 29 Jul 2026 17:48:29 +0800 Subject: [PATCH 6/6] fix(sandbox): generate UUID v4 key, validate maxRetries, and add EAI_AGAIN generateIdempotencyKey now outputs RFC 9562 UUID v4 format. normalizeMaxRetries validates non-integer/negative values from both options and SANDBOX_RETRY_MAX environment variable. EAI_AGAIN added to retryable network error codes. Remove git clone integration test. --- qiniu/sandbox/client.js | 28 +++++++++++++++++++---- test/sandbox_client.test.js | 39 +++++++++++++++++++++++++++++++- test/sandbox_integration.test.js | 28 ----------------------- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 4bffd76..e8c2bf7 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -17,14 +17,32 @@ const { } = require('./util'); function generateIdempotencyKey () { - return crypto.randomBytes(16).toString('hex'); + const bytes = crypto.randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } const retryableNetworkErrorCodes = [ - 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', + 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE' ]; +function normalizeMaxRetries (value, source, allowString) { + if (typeof value === 'string') { + if (!allowString || !/^\d+$/.test(value)) { + throw new SandboxError(`${source} must be a non-negative integer`); + } + value = parseInt(value, 10); + } + if (typeof value !== 'number' || !isFinite(value) || + Math.floor(value) !== value || value < 0) { + throw new SandboxError(`${source} must be a non-negative integer`); + } + return value; +} + function normalizeSandboxCreateOptions (opts) { opts = opts || {}; const body = {}; @@ -165,10 +183,12 @@ function SandboxClient (opts) { httpsAgent: normalized.httpsAgent }); if (opts.maxRetries !== undefined && opts.maxRetries !== null) { - this.maxRetries = opts.maxRetries; + this.maxRetries = normalizeMaxRetries(opts.maxRetries, 'maxRetries', false); } else { const envVal = process.env.SANDBOX_RETRY_MAX; - this.maxRetries = envVal && /^\d+$/.test(envVal) ? parseInt(envVal, 10) : 5; + this.maxRetries = envVal + ? normalizeMaxRetries(envVal, 'SANDBOX_RETRY_MAX', true) + : 5; } } diff --git a/test/sandbox_client.test.js b/test/sandbox_client.test.js index 359626d..067365f 100644 --- a/test/sandbox_client.test.js +++ b/test/sandbox_client.test.js @@ -7,6 +7,43 @@ const { } = require('./sandbox_helpers'); describe('test sandbox client module', function () { + it('rejects invalid retry counts from options and environment', function () { + (() => new qiniu.sandbox.SandboxClient({ maxRetries: -1 })) + .should.throw(/maxRetries/); + + const previous = process.env.SANDBOX_RETRY_MAX; + process.env.SANDBOX_RETRY_MAX = 'invalid'; + try { + (() => new qiniu.sandbox.SandboxClient()).should.throw(/SANDBOX_RETRY_MAX/); + } finally { + if (previous === undefined) { + delete process.env.SANDBOX_RETRY_MAX; + } else { + process.env.SANDBOX_RETRY_MAX = previous; + } + } + }); + + it('generates a Node.js 6 compatible UUID v4 idempotency key', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + let request; + client.httpClient.sendRequest = receivedRequest => { + request = receivedRequest; + return Promise.resolve({ + ok: () => true, + data: { sandboxID: 'sbx_uuid' } + }); + }; + + return client.createSandbox({ template: 'base' }).then(() => { + request.urllibOptions.headers['Idempotency-Key'] + .should.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + }); + it('retries retryable responses and keeps the idempotency key stable', function () { const client = new qiniu.sandbox.SandboxClient({ endpoint: 'http://sandbox.test', @@ -58,7 +95,7 @@ describe('test sandbox client module', function () { apiKey: 'sandbox-key' }); - ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT'].forEach(code => { + ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'].forEach(code => { client._isRetryable({ code }).should.eql(true); }); }); diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js index 54fe3c8..12a7106 100644 --- a/test/sandbox_integration.test.js +++ b/test/sandbox_integration.test.js @@ -451,34 +451,6 @@ describeIntegration('sandbox integration', function () { integrationLog('default template integration completed', templates.length); }); }); - - it('retries sandbox creation with git clone timeout', function () { - const repoURL = process.env.GIT_REPO_URL; - const token = process.env.GITHUB_TOKEN; - if (!repoURL || !token) { - console.log('skip: GIT_REPO_URL / GITHUB_TOKEN not set'); - return Promise.resolve(); - } - - const client = sandboxClient(); - const idempotencyKey = `sdk-retry-git-${Math.floor(Date.now() / 1000)}`; - console.log(`repo: ${repoURL}, idempotencyKey: ${idempotencyKey}`); - - return client.createSandbox({ - template: config.template, - timeout: 300, - resources: [{ - type: 'github_repository', - url: repoURL, - mount_path: '/repo', - authorization_token: token - }], - idempotencyKey - }).then(info => { - console.log('sandbox created:', info.sandboxID); - return client.deleteSandbox(info.sandboxID); - }); - }); }); if (!config.apiKey) {