Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions examples/sandbox_idempotency_retry.js
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export declare namespace sandbox {
timeout?: number | number[];
httpAgent?: HttpAgent;
httpsAgent?: HttpsAgent;
maxRetries?: number;
}

interface GitRepositoryResource {
Expand Down Expand Up @@ -190,6 +191,7 @@ export declare namespace sandbox {
injections?: SandboxInjection[];
resources?: SandboxResource[];
client?: SandboxClient;
idempotencyKey?: string;
}

interface SandboxConnectOptions extends SandboxClientOptions {
Expand Down
87 changes: 82 additions & 5 deletions qiniu/sandbox/client.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -15,6 +16,33 @@ const {
timeoutSecondsFromOptions
} = require('./util');

Comment thread
DROWNING2003 marked this conversation as resolved.
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'
Comment thread
DROWNING2003 marked this conversation as resolved.
];

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 = {};
Expand Down Expand Up @@ -143,6 +171,7 @@ function normalizeClientOptions (opts) {
}

function SandboxClient (opts) {
opts = opts || {};
const normalized = normalizeClientOptions(opts);
this.endpoint = normalized.endpoint;
this.apiKey = normalized.apiKey;
Expand All @@ -153,6 +182,14 @@ function SandboxClient (opts) {
httpAgent: normalized.httpAgent,
httpsAgent: normalized.httpsAgent
});
if (opts.maxRetries !== undefined && opts.maxRetries !== null) {
Comment thread
DROWNING2003 marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -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'];
}
Expand Down Expand Up @@ -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));
Comment thread
DROWNING2003 marked this conversation as resolved.
};

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));
};
Expand All @@ -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();
Comment thread
DROWNING2003 marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions qiniu/sandbox/sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
93 changes: 93 additions & 0 deletions test/sandbox_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading