mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
fix(api): 技术错误提示增加15秒去重缓存,避免并发失败刷屏
This commit is contained in:
@@ -180,6 +180,93 @@ describe('MoviePilot API client', () => {
|
|||||||
expect(error.response?.data).toEqual(envelope)
|
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 () => {
|
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||||
const reportConnectionFailure = vi.fn()
|
const reportConnectionFailure = vi.fn()
|
||||||
const adapter: AxiosAdapter = async () => {
|
const adapter: AxiosAdapter = async () => {
|
||||||
|
|||||||
+47
-6
@@ -98,6 +98,9 @@ const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
|
|||||||
timeout: 'Request timeout',
|
timeout: 'Request timeout',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 技术类失败提示的去重窗口:后端异常时并发请求会得到相同错误,窗口内只提示一次避免刷屏。 */
|
||||||
|
const TECHNICAL_ERROR_DEDUP_MS = 15000
|
||||||
|
|
||||||
interface ApiRequestErrorOptions<T> {
|
interface ApiRequestErrorOptions<T> {
|
||||||
businessFailure?: boolean
|
businessFailure?: boolean
|
||||||
cause?: unknown
|
cause?: unknown
|
||||||
@@ -187,11 +190,15 @@ export function createApiClients(options: CreateApiClientsOptions = {}): {
|
|||||||
const api = axios.create(axiosConfig)
|
const api = axios.create(axiosConfig)
|
||||||
const pluginApi = axios.create(axiosConfig)
|
const pluginApi = axios.create(axiosConfig)
|
||||||
|
|
||||||
|
// 技术类失败提示的去重缓存:同一消息在窗口内只提示一次,避免后端异常时大量并发请求刷屏。
|
||||||
|
// 两个客户端共用同一份缓存,保证重复提示被整体收敛。
|
||||||
|
const technicalErrorDedup = new Map<string, number>()
|
||||||
|
|
||||||
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
|
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
|
||||||
setupInstance?.(api)
|
setupInstance?.(api)
|
||||||
setupInstance?.(pluginApi)
|
setupInstance?.(pluginApi)
|
||||||
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage)
|
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
|
||||||
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage)
|
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
api: api as DataApiClient,
|
api: api as DataApiClient,
|
||||||
@@ -206,11 +213,15 @@ function installResponseInterceptors(
|
|||||||
hooks?: ApiClientHooks,
|
hooks?: ApiClientHooks,
|
||||||
notifier?: ApiFeedbackNotifier,
|
notifier?: ApiFeedbackNotifier,
|
||||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||||
|
technicalErrorDedup?: Map<string, number>,
|
||||||
) {
|
) {
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
response => {
|
response => {
|
||||||
hooks?.markServerOnline?.()
|
hooks?.markServerOnline?.()
|
||||||
|
|
||||||
|
// 服务恢复在线后清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。
|
||||||
|
technicalErrorDedup?.clear()
|
||||||
|
|
||||||
if (isBinarySuccess(response)) return response.data
|
if (isBinarySuccess(response)) return response.data
|
||||||
|
|
||||||
const payload: unknown = response.data
|
const payload: unknown = response.data
|
||||||
@@ -223,7 +234,7 @@ function installResponseInterceptors(
|
|||||||
request: response.request,
|
request: response.request,
|
||||||
response,
|
response,
|
||||||
})
|
})
|
||||||
notifyFailure(response.config.feedback, notifier, error.message)
|
notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup)
|
||||||
return Promise.reject(error)
|
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)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -359,8 +370,38 @@ function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
|
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
|
||||||
function notifyFailure(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
function notifyFailure(
|
||||||
if (mode !== 'silent' && message) notifier?.error(message)
|
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 模式用于显式要求请求层展示后端成功消息。 */
|
/** all 模式用于显式要求请求层展示后端成功消息。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user