fix(api): 技术错误提示增加15秒去重缓存,避免并发失败刷屏

This commit is contained in:
jxxghp
2026-08-17 11:11:40 +08:00
parent 635f32a6c4
commit 03c1b893b8
2 changed files with 134 additions and 6 deletions
+87
View File
@@ -180,6 +180,93 @@ describe('MoviePilot API client', () => {
expect(error.response?.data).toEqual(envelope)
})
it('相同技术错误在去重窗口内只提示一次,避免并发失败刷屏', async () => {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
expect(notifier.error).toHaveBeenCalledWith('Server exploded')
})
it('去重窗口过期后相同技术错误可以再次提示', async () => {
vi.useFakeTimers()
try {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
// 窗口(15 秒)过后,新一轮相同错误应能再次提示。
vi.advanceTimersByTime(15000)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('成功响应后清空去重缓存,让后续技术错误可以再次提示', async () => {
vi.useFakeTimers()
try {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
// 服务恢复后缓存应被清空,即使仍在窗口内,相同错误也可再次提示。
api.defaults.adapter = resolveWith<ApiResponse<{ ok: boolean }>>({
success: true,
message: '',
data: { ok: true },
})
await api.get('/ping')
api.defaults.adapter = rejectWith({ message: 'Server exploded' }, 500)
await api.get('/c').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('不同消息的技术错误互不影响,分别提示', async () => {
const adapter: AxiosAdapter = async config => {
const message = config.url === '/a' ? 'First failure' : 'Second failure'
const response = createResponse(config, { message }, 500)
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
}
const { api } = createApiClients({ adapter, notifier })
await api.get('/a').catch(reason => reason)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
expect(notifier.error).toHaveBeenCalledWith('First failure')
expect(notifier.error).toHaveBeenCalledWith('Second failure')
})
it('业务失败不参与技术错误去重,始终逐条提示', async () => {
const envelope: ApiResponse<null> = { success: false, message: 'Cannot save', data: null }
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
await api.post('/setting', {}).catch(reason => reason)
await api.post('/setting', {}).catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
})
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
const reportConnectionFailure = vi.fn()
const adapter: AxiosAdapter = async () => {
+47 -6
View File
@@ -98,6 +98,9 @@ const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
timeout: 'Request timeout',
}
/** 技术类失败提示的去重窗口:后端异常时并发请求会得到相同错误,窗口内只提示一次避免刷屏。 */
const TECHNICAL_ERROR_DEDUP_MS = 15000
interface ApiRequestErrorOptions<T> {
businessFailure?: boolean
cause?: unknown
@@ -187,11 +190,15 @@ export function createApiClients(options: CreateApiClientsOptions = {}): {
const api = axios.create(axiosConfig)
const pluginApi = axios.create(axiosConfig)
// 技术类失败提示的去重缓存:同一消息在窗口内只提示一次,避免后端异常时大量并发请求刷屏。
// 两个客户端共用同一份缓存,保证重复提示被整体收敛。
const technicalErrorDedup = new Map<string, number>()
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
setupInstance?.(api)
setupInstance?.(pluginApi)
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage)
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage)
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
return {
api: api as DataApiClient,
@@ -206,11 +213,15 @@ function installResponseInterceptors(
hooks?: ApiClientHooks,
notifier?: ApiFeedbackNotifier,
resolveFallbackMessage?: ApiFallbackMessageResolver,
technicalErrorDedup?: Map<string, number>,
) {
instance.interceptors.response.use(
response => {
hooks?.markServerOnline?.()
// 服务恢复在线后清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。
technicalErrorDedup?.clear()
if (isBinarySuccess(response)) return response.data
const payload: unknown = response.data
@@ -223,7 +234,7 @@ function installResponseInterceptors(
request: response.request,
response,
})
notifyFailure(response.config.feedback, notifier, error.message)
notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup)
return Promise.reject(error)
}
@@ -280,7 +291,7 @@ function installResponseInterceptors(
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
// 不在请求层逐个弹出,避免后端重启时刷屏。
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message)
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message, technicalErrorDedup)
return Promise.reject(error)
},
)
@@ -359,8 +370,38 @@ function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessa
}
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
function notifyFailure(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
if (mode !== 'silent' && message) notifier?.error(message)
function notifyFailure(
mode: ApiFeedbackMode | undefined,
notifier: ApiFeedbackNotifier | undefined,
message: string,
technicalErrorDedup?: Map<string, number>,
) {
if (mode !== 'silent' && message && !isTechnicalErrorCached(message, technicalErrorDedup)) {
notifier?.error(message)
}
}
/**
* 技术错误去重:相同消息在窗口内只提示一次。
*
* 后端异常或重启时大量并发请求会携带同一技术错误(协议错误、HTTP 5xx 等),
* 逐条弹出会占满屏幕;此处以消息为键缓存最近提示时间,窗口内重复消息不再提示。
*/
function isTechnicalErrorCached(message: string, dedup?: Map<string, number>): boolean {
if (!dedup) return false
const now = Date.now()
const lastShownAt = dedup.get(message)
dedup.set(message, now)
if (lastShownAt !== undefined && now - lastShownAt < TECHNICAL_ERROR_DEDUP_MS) {
return true
}
// 顺带清理过期缓存项,避免长时间运行后缓存无限增长。
if (dedup.size > 64) {
for (const [key, at] of dedup) {
if (now - at >= TECHNICAL_ERROR_DEDUP_MS) dedup.delete(key)
}
}
return false
}
/** all 模式用于显式要求请求层展示后端成功消息。 */