146 lines
4.2 KiB
TypeScript
146 lines
4.2 KiB
TypeScript
/**
|
||
* 轮询状态机(Task 95)。
|
||
*
|
||
* 收敛轮询 UI 的深色主题无关状态语义:错误提示(error)、重试(retry)、
|
||
* 终态刷新(markRefreshed)的有界状态转换。
|
||
*
|
||
* 状态流:idle → start → polling →(fail)→ retrying →(fail…)→ failed
|
||
* └→ succeed → done(终态;仅刷新历史,不重复计数)
|
||
*
|
||
* 有界语义:
|
||
* - 重试次数有上限(maxAttempts),超过后进入 failed,不再重试;
|
||
* - 终态幂等:重复 succeed 不覆盖状态、不重复计数;markRefreshed 只计一次;
|
||
* - 未 start 时 fail/succeed 抛错;isTerminal 依赖抛错时调用失败但状态
|
||
* 零变更,恢复后可继续。
|
||
*/
|
||
export interface PollingStateMachineOptions {
|
||
/** 重试上限,必须为正整数,达到上限后失败不再重试 */
|
||
maxAttempts: number
|
||
/** 判定终态,默认 SUCCESS/FAILED;抛错时调用失败且状态不变 */
|
||
isTerminal?: (status: string) => boolean
|
||
}
|
||
|
||
export interface PollingStateMachine {
|
||
/** idle | polling | retrying | done | failed */
|
||
status: string
|
||
/** 是否仍在轮询(polling 或 retrying) */
|
||
retrying: boolean
|
||
attempts: number
|
||
errorCount: number
|
||
/** 最近一次终态状态字符串(未终态为空串) */
|
||
terminalStatus: string
|
||
/** 最近一次错误消息 */
|
||
lastError: string
|
||
/** 是否已刷新历史(终态后至多一次) */
|
||
refreshed: boolean
|
||
refreshCount: number
|
||
start: () => void
|
||
/** 记录一次失败;未超上限进入 retrying,超上限进入 failed */
|
||
fail: (message: string) => void
|
||
/** 记录一次成功;终态后重复调用幂等 */
|
||
succeed: (status: string) => void
|
||
/** 标记终态历史已刷新;仅 done/failed 后首个调用生效 */
|
||
markRefreshed: () => void
|
||
/** 已发生的重试序号列表 */
|
||
retries: () => number[]
|
||
}
|
||
|
||
export function createPollingStateMachine(options: PollingStateMachineOptions): PollingStateMachine {
|
||
if (typeof options !== 'object' || options == null) {
|
||
throw new Error('options 必须是对象')
|
||
}
|
||
if (!(options.maxAttempts > 0)) {
|
||
throw new Error('maxAttempts 必须为正数: ' + options.maxAttempts)
|
||
}
|
||
if (!Number.isInteger(options.maxAttempts)) {
|
||
throw new Error('maxAttempts 必须为整数: ' + options.maxAttempts)
|
||
}
|
||
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
|
||
if (typeof isTerminal !== 'function') {
|
||
throw new Error('isTerminal 必须是函数')
|
||
}
|
||
|
||
let status = 'idle'
|
||
let attempts = 0
|
||
let errorCount = 0
|
||
let terminalStatus = ''
|
||
let lastError = ''
|
||
let refreshCount = 0
|
||
const retrySequence: number[] = []
|
||
|
||
function start() {
|
||
if (status === 'done' || status === 'failed') return
|
||
status = 'polling'
|
||
attempts = 0
|
||
}
|
||
|
||
function fail(message: string) {
|
||
if (status === 'idle') {
|
||
throw new Error('未开始轮询')
|
||
}
|
||
if (status === 'done' || status === 'failed') return
|
||
attempts += 1
|
||
errorCount += 1
|
||
lastError = message
|
||
if (attempts >= options.maxAttempts) {
|
||
status = 'failed'
|
||
} else {
|
||
retrySequence.push(attempts)
|
||
status = 'retrying'
|
||
}
|
||
}
|
||
|
||
function succeed(terminal: string) {
|
||
if (status === 'idle') {
|
||
throw new Error('未开始轮询')
|
||
}
|
||
if (status === 'done' || status === 'failed') return
|
||
if (!isTerminal(terminal)) {
|
||
// 非终态响应:按一次失败计入,沿用重试语义
|
||
fail('非终态响应: ' + terminal)
|
||
return
|
||
}
|
||
attempts += 1
|
||
terminalStatus = terminal
|
||
status = 'done'
|
||
}
|
||
|
||
function markRefreshed() {
|
||
if (status !== 'done' && status !== 'failed') return
|
||
if (refreshCount > 0) return
|
||
refreshCount = 1
|
||
}
|
||
|
||
return {
|
||
get status() {
|
||
return status
|
||
},
|
||
get retrying() {
|
||
return status === 'polling' || status === 'retrying'
|
||
},
|
||
get attempts() {
|
||
return attempts
|
||
},
|
||
get errorCount() {
|
||
return errorCount
|
||
},
|
||
get terminalStatus() {
|
||
return terminalStatus
|
||
},
|
||
get lastError() {
|
||
return lastError
|
||
},
|
||
get refreshed() {
|
||
return refreshCount > 0
|
||
},
|
||
get refreshCount() {
|
||
return refreshCount
|
||
},
|
||
start,
|
||
fail,
|
||
succeed,
|
||
markRefreshed,
|
||
retries: () => [...retrySequence],
|
||
}
|
||
}
|