139 lines
5.2 KiB
JavaScript
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);
|