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..dc094cd --- /dev/null +++ b/examples/sandbox_idempotency_retry.js @@ -0,0 +1,45 @@ +/** + * 幂等重试示例:同一幂等键连续两次 Create,验证返回同一沙箱。 + */ +const qiniu = require('../'); + +const apiKey = process.env.QINIU_SANDBOX_API_KEY; +if (!apiKey) { + console.error('请设置 QINIU_SANDBOX_API_KEY 环境变量'); + process.exit(1); +} + +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 +}; + +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); + + const sameSandbox = sb1.sandboxId === sb2.sandboxId; + if (sameSandbox) { + console.log('\n幂等重试验证通过:两次创建返回同一沙箱'); + } else { + console.log('\nWARNING: 两次创建返回不同沙箱'); + } + + const cleanup = sameSandbox + ? sb1.kill() + : Promise.all([sb1.kill(), sb2.kill()]); + return cleanup.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 7955f6b..e8c2bf7 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,33 @@ const { timeoutSecondsFromOptions } = require('./util'); +function generateIdempotencyKey () { + 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', '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 = {}; @@ -143,6 +171,7 @@ function normalizeClientOptions (opts) { } function SandboxClient (opts) { + opts = opts || {}; const normalized = normalizeClientOptions(opts); this.endpoint = normalized.endpoint; this.apiKey = normalized.apiKey; @@ -153,6 +182,14 @@ function SandboxClient (opts) { httpAgent: normalized.httpAgent, httpsAgent: normalized.httpsAgent }); + if (opts.maxRetries !== undefined && opts.maxRetries !== null) { + this.maxRetries = normalizeMaxRetries(opts.maxRetries, 'maxRetries', false); + } else { + const envVal = process.env.SANDBOX_RETRY_MAX; + this.maxRetries = envVal + ? normalizeMaxRetries(envVal, 'SANDBOX_RETRY_MAX', true) + : 5; + } } SandboxClient.prototype._headers = function (authType) { @@ -199,6 +236,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 +291,39 @@ 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; + } + 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', + '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)) { + 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; + }); + } + return attempt(0); +}; + SandboxClient.prototype.listSandboxes = function (opts) { return this._request('GET', appendQuery('/sandboxes', opts)); }; @@ -260,11 +333,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 +431,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/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; } diff --git a/test/sandbox_client.test.js b/test/sandbox_client.test.js index f2db9f2..067365f 100644 --- a/test/sandbox_client.test.js +++ b/test/sandbox_client.test.js @@ -7,6 +7,99 @@ 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', + 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', 'EAI_AGAIN'].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;