diff --git a/website/deploy/deploy.mjs b/website/deploy/deploy.mjs index 370e4388..21b5944a 100644 --- a/website/deploy/deploy.mjs +++ b/website/deploy/deploy.mjs @@ -12,6 +12,24 @@ export const PROJECT = { projectId: 'makers-s7dxi8u5vidy', name: 'cs-notes' }; export const ORIGIN = 'https://cyc2018.xyz'; const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +// 腾讯云接口偶尔网络超时或连接失败,这类错误重试;令牌、权限等接口返回的错误不重试 +export function isTransient(error) { + const text = [error?.message, error?.code, error?.cause?.code, error?.cause?.message].filter(Boolean).join(' '); + return /fetch failed|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|UND_ERR|socket hang up/i.test(text); +} + +async function retrying(label, task, sleep, attempts = 3) { + for (let attempt = 1; ; attempt++) { + try { + return await task(); + } catch (error) { + if (attempt >= attempts || !isTransient(error)) throw error; + console.log(`${label}遇到网络错误,${attempt * 15} 秒后重试(第 ${attempt} 次)`); + await sleep(attempt * 15_000); + } + } +} + async function download(origin, path, revision, fetcher) { const url = new URL(path.split('/').map(encodeURIComponent).join('/'), origin + '/'); url.searchParams.set('release', revision); @@ -100,15 +118,15 @@ export async function deploySite({ directory, archive = resolve(directory, '../. // Always bind the upload to this validated dist; a cached ZIP may belong to an older build. await packageRelease(directory, archive); const makers = await getClient(); - const project = await makers.projects.get({ projectId: PROJECT.projectId }); + const project = await retrying('读取腾讯云项目', () => makers.projects.get({ projectId: PROJECT.projectId }), sleep); if (project.projectId !== PROJECT.projectId || project.name !== PROJECT.name) { throw new Error('腾讯云项目标识与预期不一致,已停止发布。'); } - deployment = await makers.deployments.deploy({ + deployment = await retrying('上传和部署', () => makers.deployments.deploy({ projectId: PROJECT.projectId, artifact: { archive: resolve(archive) }, env: 'Production', wait: true, timeout: 600, pollInterval: 5, onStatusChange: ({ deployment }) => console.log(`腾讯云部署状态:${deployment.status}`), - }); + }), sleep); if (deployment.status !== 'Success') { throw new Error(`腾讯云部署未成功:${deployment.status}`); } diff --git a/website/deploy/deploy.test.mjs b/website/deploy/deploy.test.mjs index 2bed8dcd..e70865a1 100644 --- a/website/deploy/deploy.test.mjs +++ b/website/deploy/deploy.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; -import { deploySite, PROJECT } from './deploy.mjs'; +import { deploySite, isTransient, PROJECT } from './deploy.mjs'; const executeFile = promisify(execFile); async function saveManifest(directory, files, sourceCommit = 'source') { @@ -85,6 +85,35 @@ test('新版本只向固定项目发布 dist,等候 Success 后重试正式域 assert.equal(retries, 1); }); +test('腾讯云接口的网络错误重试,令牌和权限错误不重试', async t => { + const { directory, files } = await fixture(t); + let published = false, gets = 0, deploys = 0; + const waits = []; + const report = await deploySite({ directory, attempts: 1, sleep: async ms => { waits.push(ms); }, + fetcher: async url => published ? new Response(files.get(decodeURIComponent(url.pathname.slice(1)))) : new Response('{}'), + getClient: async () => ({ + projects: { get: async () => { if (++gets === 1) throw new TypeError('fetch failed'); return PROJECT; } }, + deployments: { deploy: async () => { + if (++deploys === 1) throw new Error('Request timed out after 30s'); + published = true; + return { status: 'Success', deploymentId: 'retried' }; + } }, + }), + }); + assert.equal(report.deploymentId, 'retried'); + assert.deepEqual([gets, deploys], [2, 2]); + assert.deepEqual(waits, [15_000, 15_000]); + + const denied = Object.assign(new Error('The token is invalid'), { name: 'AuthFailureError', code: 'AuthFailure' }); + let calls = 0; + await assert.rejects(deploySite({ directory, sleep: async () => assert.fail('权限错误不应重试'), fetcher: async () => new Response('{}'), + getClient: async () => ({ projects: { get: async () => { calls++; throw denied; } }, deployments: { deploy: () => assert.fail('不应上传') } }), + }), /token is invalid/); + assert.equal(calls, 1); + assert.equal(isTransient(Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNRESET' } })), true); + assert.equal(isTransient(new Error('腾讯云部署未成功:Failed')), false); +}); + test('项目身份不符时拒绝发布', async t => { const { directory } = await fixture(t); await assert.rejects(deploySite({ directory, fetcher: async () => new Response('{}'),