Files

139 lines
5.2 KiB
JavaScript

/**
* 运行时验证超时行为:mock 上游故意拖慢,确认
* 1) 单发路径用的是长预算(不会被短超时掐断)
* 2) 超时后错误文案归因于「本服务」,而不是伪装成上游 504
* 3) 超时事件带 reason='timeout',让前端能显示「等待超时」
*
* 运行: node test/verify-timeout.js
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import http from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FFMPEG = (await import('ffmpeg-static')).default;
function runBin(bin, args) {
return new Promise((res, rej) => {
const c = spawn(bin, args, { windowsHide: true });
let e = '';
c.stderr.on('data', d => e += d);
c.on('close', code => code === 0 ? res() : rej(new Error(e)));
c.on('error', rej);
});
}
// mock 上游:延迟 DELAY ms 后才响应(模拟硅基流动处理长音频)
const DELAY = 3000;
let seen = 0;
const mock = http.createServer((req, res) => {
const cs = [];
req.on('data', d => cs.push(d));
req.on('end', () => {
seen += 1;
setTimeout(() => {
const data = Buffer.from(JSON.stringify({ text: '慢速结果', usage: { seconds: 10 } }), 'utf8');
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(data);
}, DELAY);
});
});
await new Promise(r => mock.listen(0, '127.0.0.1', r));
const mockUrl = `http://127.0.0.1:${mock.address().port}/v1`;
const port = 19911;
// 关键:把单发超时压到 800ms < DELAY(3000ms),强制触发本地超时
const proc = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], {
env: {
...process.env,
PORT: String(port), HOST: '127.0.0.1',
SILICONFLOW_BASE_URL: mockUrl, OPENROUTER_BASE_URL: mockUrl,
UPSTREAM_TIMEOUT_MS: '800', // 故意小于 mock 延迟
CHUNK_TIMEOUT_MS: '800',
RETRY_ATTEMPTS: '2', // 少重试几次,测试快些
RETRY_BASE_MS: '50',
},
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
let log = '';
proc.stdout.on('data', d => log += d);
proc.stderr.on('data', d => log += d);
for (let i = 0; i < 120; i++) {
try { const r = await fetch(`http://127.0.0.1:${port}/api/health`); if (r.ok) break; } catch {}
await new Promise(r => setTimeout(r, 200));
}
const tmp = await mkdtemp(path.join(tmpdir(), 'stt-tmo-'));
const src = path.join(tmp, 'a.mp3');
await runBin(FFMPEG, ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=8000:cl=mono',
'-t', '5', '-c:a', 'libmp3lame', '-b:a', '32k', src]);
const fd = new FormData();
fd.append('file', new Blob([await readFile(src)]), 'a.mp3');
fd.append('provider', 'siliconflow');
fd.append('timestamps', 'false');
const res = await fetch(`http://127.0.0.1:${port}/api/transcribe`, {
method: 'POST', headers: { 'X-Provider-Key': 'sk-test' }, body: fd,
});
assert.equal(res.status, 200);
const events = [];
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const raw = buf.slice(0, i).trim();
buf = buf.slice(i + 2);
if (raw.startsWith('data:')) { try { events.push(JSON.parse(raw.slice(5))); } catch {} }
}
}
const retries = events.filter(e => e.type === 'chunk' && e.status === 'retry');
const err = events.find(e => e.type === 'error');
console.log('事件序列:', events.map(e => `${e.type}${e.status ? ':' + e.status : ''}`).join(' → '));
console.log('\n重试事件:');
retries.forEach(e => console.log(' ', e.message));
console.log('\n最终错误:', err ? err.error : '(无)');
// 1. 确实重试了
assert.ok(retries.length >= 1, '超时应触发重试');
// 2. 重试事件的文案必须说「等待超时」,不能谎称是服务端状态码
retries.forEach(r => {
assert.match(r.message, /等待超时/,
`重试文案应说明是等待超时,实际: ${r.message}`);
assert.doesNotMatch(r.message, /服务端 504/,
'本地超时不能伪装成上游 504 —— 这正是当初排查跑偏的原因');
});
console.log('\n✓ 重试文案正确归因为「等待超时」,未伪装成上游 504');
// 3. 最终错误必须明确说明是本服务超时
assert.ok(err, '应返回 error 事件');
assert.match(err.error, /本服务超时,非上游错误/,
`错误文案应说明是本服务超时,实际: ${err.error}`);
assert.equal(err.code, 'CLIENT_TIMEOUT', '应带 CLIENT_TIMEOUT 标记');
console.log('✓ 错误带 code=CLIENT_TIMEOUT,文案说明「非上游错误」');
// 4. 确认 mock 确实被请求了(说明超时发生在等待响应阶段,而不是没发出去)
assert.ok(seen >= 1, 'mock 上游应收到请求');
console.log(`✓ 上游收到 ${seen} 次请求(超时发生在等待响应阶段)`);
proc.kill('SIGKILL');
mock.close();
await rm(tmp, { recursive: true, force: true });
console.log('\n超时行为验证通过');
process.exit(0);