Skip to content

Commit 17066b2

Browse files
fix(probe): add per-request timeout (default 3s) to prevent hanging on unreachable hosts (#87)
* fix(probe): add per-request timeout (default 3s) to prevent hanging on unreachable hosts * Update src/probe.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/probe.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 897732b commit 17066b2

2 files changed

Lines changed: 106 additions & 13 deletions

File tree

src/probe.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ beforeEach(() => {
66
vi.clearAllMocks()
77
})
88

9+
/** 创建一个永远挂起(直到 signal abort)的请求 mock */
10+
const hangingRequest = (_url: string, signal: AbortSignal) => {
11+
return new Promise<string>((_, reject) => {
12+
if (signal.aborted) return reject(signal.reason)
13+
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
14+
})
15+
}
16+
917
describe('probeRace', () => {
1018
it('首个 URL 最快时应返回其结果', async () => {
1119
const request = vi.fn().mockResolvedValue('result-1')
@@ -129,4 +137,74 @@ describe('probeRace', () => {
129137
expect(url).toBe('https://only.com')
130138
expect(request).toHaveBeenCalledTimes(1)
131139
})
140+
141+
describe('超时处理', () => {
142+
it('单个请求超时应视为失败', async () => {
143+
await expect(probeRace({
144+
tag: 'test',
145+
urls: ['https://a.com'],
146+
timeout: 100,
147+
request: hangingRequest,
148+
})).rejects.toThrow('所有探针均不可用')
149+
})
150+
151+
it('所有请求超时应抛出错误', async () => {
152+
await expect(probeRace({
153+
tag: 'test',
154+
urls: ['https://a.com', 'https://b.com'],
155+
staggerDelay: 50,
156+
timeout: 200,
157+
request: hangingRequest,
158+
})).rejects.toThrow('所有探针均不可用')
159+
})
160+
161+
it('部分超时时应返回成功的那个', async () => {
162+
let call = 0
163+
const request = vi.fn().mockImplementation((url: string, signal: AbortSignal) => {
164+
call++
165+
if (call === 1) return hangingRequest(url, signal)
166+
return Promise.resolve('result-2')
167+
})
168+
169+
const { result, url } = await probeRace({
170+
tag: 'test',
171+
urls: ['https://slow.com', 'https://fast.com'],
172+
staggerDelay: 50,
173+
timeout: 5000,
174+
request,
175+
})
176+
expect(result).toBe('result-2')
177+
expect(url).toBe('https://fast.com')
178+
})
179+
180+
it('请求函数应收到带超时的组合 signal', async () => {
181+
let capturedSignal: AbortSignal | undefined
182+
const request = vi.fn().mockImplementation((_url: string, signal: AbortSignal) => {
183+
capturedSignal = signal
184+
return Promise.resolve('ok')
185+
})
186+
187+
await probeRace({
188+
tag: 'test',
189+
urls: ['https://a.com'],
190+
timeout: 5000,
191+
request,
192+
})
193+
194+
expect(capturedSignal).toBeDefined()
195+
// 成功后 signal 应已被 abort(controller.abort)
196+
expect(capturedSignal!.aborted).toBe(true)
197+
})
198+
199+
it('默认超时为 3000ms', async () => {
200+
const request = vi.fn().mockResolvedValue('ok')
201+
await probeRace({
202+
tag: 'test',
203+
urls: ['https://a.com'],
204+
request,
205+
})
206+
// 不传 timeout 也能正常运行(默认 3s 足够)
207+
expect(request).toHaveBeenCalledTimes(1)
208+
})
209+
})
132210
})

src/probe.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export interface ProbeOptions<T> {
55
urls: string[]
66
/** 每个探针之间的延迟(毫秒),默认 300 */
77
staggerDelay?: number
8+
/** 单个请求的超时时间(毫秒),默认 3000 */
9+
timeout?: number
810
/** 发起请求的函数 */
911
request: (url: string, signal: AbortSignal) => Promise<T>
1012
/** 日志标签 */
@@ -20,16 +22,38 @@ export interface ProbeResult<T> {
2022
elapsed: number
2123
}
2224

25+
/**
26+
* 可中断的延迟
27+
*
28+
* signal 被 abort 时立即 reject,用于取消尚未启动的探针
29+
*/
30+
const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> => {
31+
return new Promise((resolve, reject) => {
32+
if (signal.aborted) return reject(signal.reason)
33+
const timer = setTimeout(() => {
34+
signal.removeEventListener('abort', onAbort)
35+
resolve()
36+
}, ms)
37+
const onAbort = () => {
38+
clearTimeout(timer)
39+
reject(signal.reason)
40+
}
41+
signal.addEventListener('abort', onAbort, { once: true })
42+
})
43+
}
44+
2345
/**
2446
* 通用探针竞速工具
2547
*
2648
* 同时向多个 URL 发起请求,首个 URL 立即请求,后续 URL 按 staggerDelay 延迟逐个启动。
49+
* 每个请求都有独立的超时控制(默认 3s),超时视为失败。
2750
* 任一成功后通过 AbortController 取消剩余请求和定时器。
51+
* 只有所有 URL 都失败或超时时才抛出错误。
2852
*
2953
* @returns 最快成功的结果、对应的 URL 和耗时
3054
*/
3155
export const probeRace = async <T>(options: ProbeOptions<T>): Promise<ProbeResult<T>> => {
32-
const { urls, staggerDelay = 300, request, tag } = options
56+
const { urls, staggerDelay = 300, timeout = 3000, request, tag } = options
3357
const controller = new AbortController()
3458
const startTime = Date.now()
3559

@@ -41,21 +65,12 @@ export const probeRace = async <T>(options: ProbeOptions<T>): Promise<ProbeResul
4165

4266
const probePromises = urls.map(async (url, index) => {
4367
if (index > 0) {
44-
await new Promise<void>((resolve, reject) => {
45-
const onAbort = () => {
46-
clearTimeout(timer)
47-
reject(new Error('Aborted'))
48-
}
49-
const timer = setTimeout(() => {
50-
controller.signal.removeEventListener('abort', onAbort)
51-
resolve()
52-
}, staggerDelay * index)
53-
controller.signal.addEventListener('abort', onAbort, { once: true })
54-
})
68+
await abortableDelay(staggerDelay * index, controller.signal)
5569
}
5670

5771
const probeStart = Date.now()
58-
const result = await request(url, controller.signal)
72+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
73+
const result = await request(url, signal)
5974
const probeElapsed = Date.now() - probeStart
6075
logger.info(`[${tag}] 探针 #${index + 1} 成功: ${url} (${probeElapsed}ms)`)
6176
return { result, url }

0 commit comments

Comments
 (0)