JavaScript 性能优化系列(六)接口调用优化 - 6.4 错误重试策略:智能重试机制,提高请求成功率
一、引言
在前端开发中,"请求失败"是网络交互中不可避免的场景:用户切换4G/WiFi时的网络波动、服务器临时过载导致的503错误、CDN节点故障引发的资源加载超时......这些偶发的失败往往可以通过"重试"解决。据统计,约30%的接口失败是临时性的,合理的重试策略能将请求成功率从70%提升至90%以上,显著改善用户体验------例如用户提交订单时因网络抖动失败,自动重试成功可避免用户手动操作,减少订单流失。
但重试并非"万能药",不当的重试策略反而会加剧问题:对400(参数错误)这类永久性错误重试,只会浪费网络资源;不限制重试次数可能导致"重试风暴",加重服务器负担;对非幂等的POST请求(如创建订单)盲目重试,可能造成重复下单。
错误重试策略的核心是"智能判断何时该重试、重试几次、如何重试",需平衡"成功率提升"与"资源消耗"。本章基于Vue3 + TypeScript生态(Axios拦截器、Pinia状态管理、组合式API),从原理分析、实战代码、反例避坑、评审要点到团队协作,完整拆解错误重试的落地逻辑,解决"哪些错误该重试""重试间隔如何设置""如何避免重复操作"三大核心问题。
二、错误重试的原理与核心要素
要设计有效的错误重试策略,需先理解"请求失败的类型",再掌握重试机制的核心要素,避免盲目重试。
2.1 请求失败的类型与可重试性判断
并非所有错误都适合重试,需根据"失败原因"判断"可重试性"。前端常见的请求失败类型及处理原则如下:
2.1.1 网络层错误(通常可重试)
- 错误特征:由网络传输问题导致,无明确的服务器响应(或响应不完整);
- 常见场景 :
ERR_NETWORK:网络中断(如用户切换网络、WiFi信号弱);ERR_CONNECTION_TIMEOUT:请求超时(服务器未在指定时间内响应);ERR_CONNECTION_REFUSED:服务器拒绝连接(可能是临时过载);
- 可重试性:高(这类错误多为临时性,重试大概率成功)。
2.1.2 服务器错误(部分可重试)
- 错误特征:服务器返回5xx状态码,表示服务器处理请求时发生错误;
- 常见场景 :
500 Internal Server Error:服务器内部错误(可能是临时bug);503 Service Unavailable:服务暂时不可用(如服务器正在重启、负载均衡切换);504 Gateway Timeout:网关超时(上游服务未及时响应);
- 可重试性:中(503、504通常是临时的,可重试;500需看具体原因,部分是永久错误)。
2.1.3 客户端错误(通常不可重试)
- 错误特征:服务器返回4xx状态码,表示请求存在客户端问题;
- 常见场景 :
400 Bad Request:请求参数错误(永久性错误,重试仍会失败);401 Unauthorized:未授权(需重新登录,重试无效);403 Forbidden:权限不足(永久性错误);404 Not Found:资源不存在(永久性错误);429 Too Many Requests:请求过于频繁(需限流,立即重试会加重问题);
- 可重试性:低(429是特殊情况,需延迟后重试,其他4xx错误重试无效)。
2.1.4 业务层错误(需按业务判断)
- 错误特征 :服务器返回200状态码,但业务逻辑返回失败(如
{ code: 1001, message: "库存不足" }); - 常见场景 :
- 库存不足、余额不足(永久性,重试无效);
- 临时活动已结束(可能是缓存导致的临时错误,可重试);
- 可重试性 :需业务定义(通过错误码区分,如
code: 5001表示临时错误可重试)。
2.2 错误重试的核心要素
有效的重试策略需包含"重试时机 ""重试次数 ""重试间隔 ""幂等性保障"四大要素,缺一不可。
2.2.1 重试时机(何时重试)
- 核心原则:只对"临时性错误"重试,排除"永久性错误";
- 判断依据 :
- 网络层错误(
ERR_NETWORK等)→ 重试; - 服务器错误(503、504)→ 重试;500需后端标记是否可重试(如响应头
X-Retry-After); - 客户端错误(429)→ 延迟后重试;其他4xx→ 不重试;
- 业务错误→ 仅对后端标记为"可重试"的错误码(如
code: 9999)重试;
- 网络层错误(
- 工具支持:通过Axios拦截器捕获错误,按上述规则过滤可重试错误。
2.2.2 重试次数(最多重试几次)
- 核心原则:限制最大重试次数,避免无限重试导致"重试风暴";
- 推荐策略 :
- 普通接口(如商品列表):最多重试2-3次;
- 关键接口(如订单提交):最多重试1-2次(避免重复操作);
- 低优先级接口(如统计上报):可重试3-5次(不影响用户体验);
- 注意:重试次数需结合重试间隔,次数越多,总耗时越长,需平衡"成功率"与"用户等待时间"。
2.2.3 重试间隔(多久后重试)
- 核心原则:避免重试请求集中发送,减轻服务器压力;
- 常用策略 :
- 固定间隔:每次重试间隔相同(如1秒),实现简单但可能集中请求;
- 指数退避(Exponential Backoff):重试间隔按指数增长(如1s→2s→4s),分散请求压力,是行业最佳实践;
- 随机抖动(Jitter):在指数退避基础上增加随机值(如1s±0.5s→2s±0.5s),避免多个客户端同时重试导致"峰值叠加";
- 示例:3次重试的指数退避+抖动策略:1s±0.2s → 2s±0.4s → 4s±0.8s。
2.2.4 幂等性保障(重试是否安全)
- 核心概念:幂等性指"多次执行相同操作,结果与执行一次相同";
- 必要性:对非幂等请求(如POST创建订单)重试,可能导致重复创建(如用户收到多个订单);
- 保障措施 :
- GET请求:天然幂等(仅查询,无副作用),可安全重试;
- POST请求:需后端实现幂等(如通过唯一订单号去重),前端才能重试;
- 标记非幂等接口:前端通过配置(如
idempotent: false)禁止重试,或提示用户确认后重试。
三、代码样例实战展示(Vue3 + TypeScript)
基于Vue3生态,提供4个高频场景的错误重试方案:基础指数退避重试 、基于错误类型的智能重试 、带幂等性校验的关键接口重试 、熔断机制的重试策略,代码包含完整类型定义与实现逻辑。
3.1 方案一:基础指数退避重试(Axios拦截器实现)
3.1.1 需求场景
普通查询接口(如商品列表、用户资料)需支持错误重试:网络波动或服务器临时过载时,自动重试2-3次,重试间隔采用指数退避(1s→2s→4s),失败后提示用户。
3.1.2 完整代码实现
首先定义重试相关类型与工具函数,通过Axios拦截器实现全局重试逻辑。
typescript
// src/types/retry.ts - 重试相关类型定义
import type { AxiosRequestConfig, AxiosError } from 'axios';
/**
* 重试配置选项
*/
export interface RetryOptions {
maxRetries: number; // 最大重试次数(默认3次)
retryDelay: (retryCount: number) => number; // 重试延迟函数(参数:已重试次数)
isRetryable: (error: AxiosError) => boolean; // 判断错误是否可重试
}
/**
* 带重试配置的Axios请求配置
*/
export interface RetryAxiosRequestConfig extends AxiosRequestConfig {
retry?: Partial<RetryOptions>; // 接口级别的重试配置(覆盖全局)
_retryCount?: number; // 内部使用:已重试次数(避免重复计数)
_idempotent?: boolean; // 内部使用:是否幂等(用于安全校验)
}
typescript
// src/utils/retryHelper.ts - 重试工具函数
import type { AxiosError } from 'axios';
import { RetryOptions, RetryAxiosRequestConfig } from '@/types/retry';
/**
* 默认重试配置
*/
export const defaultRetryOptions: RetryOptions = {
// 1. 默认最大重试次数:3次
maxRetries: 3,
// 2. 默认重试延迟:指数退避+随机抖动(1s±0.2s → 2s±0.4s → 4s±0.8s)
retryDelay: (retryCount) => {
const baseDelay = Math.pow(2, retryCount) * 1000; // 指数退避(2^retryCount秒)
const jitter = Math.random() * baseDelay * 0.2; // 随机抖动(±20%)
return baseDelay - jitter; // 最终延迟 = 基础延迟 - 抖动(避免固定峰值)
},
// 3. 默认可重试错误判断:网络错误、503、504
isRetryable: (error: AxiosError) => {
// 网络错误(无响应)
if (!error.response) {
return true;
}
// 服务器错误(503、504)
const status = error.response.status;
return status === 503 || status === 504;
}
};
/**
* 合并重试配置(全局配置 + 接口配置)
* @param global 全局配置
* @param local 接口配置(可选)
* @returns 合并后的配置
*/
export const mergeRetryOptions = (
global: RetryOptions,
local?: Partial<RetryOptions>
): RetryOptions => {
return { ...global, ...local };
};
/**
* 延迟函数(用于重试间隔)
* @param ms 毫秒数
* @returns Promise
*/
export const delay = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
typescript
// src/utils/axiosRetry.ts - 带重试的Axios实例
import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios';
import { defaultRetryOptions, mergeRetryOptions, delay } from '@/utils/retryHelper';
import { RetryAxiosRequestConfig, RetryOptions } from '@/types/retry';
/**
* 创建带重试机制的Axios实例
* @param options 全局重试配置(可选)
* @returns AxiosInstance
*/
export const createRetryAxios = (options?: Partial<RetryOptions>): AxiosInstance => {
// 合并全局重试配置
const globalRetryOptions: RetryOptions = {
...defaultRetryOptions,
...options
};
// 创建Axios实例
const instance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000 // 基础超时时间(10秒)
});
// 请求拦截器:初始化重试计数
instance.interceptors.request.use(
(config: RetryAxiosRequestConfig) => {
// 初始化已重试次数(首次请求为0)
if (config._retryCount === undefined) {
config._retryCount = 0;
}
// 默认标记GET请求为幂等,POST为非幂等(可通过config覆盖)
if (config._idempotent === undefined) {
config._idempotent = config.method?.toUpperCase() === 'GET';
}
return config;
},
(error: AxiosError) => Promise.reject(error)
);
// 响应拦截器:实现重试逻辑
instance.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError<unknown, RetryAxiosRequestConfig>) => {
const config = error.config;
// 无配置或已取消的请求,不重试
if (!config || config.cancelToken) {
return Promise.reject(error);
}
// 合并全局与接口的重试配置
const retryOptions = mergeRetryOptions(
globalRetryOptions,
config.retry
);
// 检查是否可重试:未达最大次数 + 错误可重试 + 幂等性校验
const canRetry =
config._retryCount! < retryOptions.maxRetries &&
retryOptions.isRetryable(error) &&
config._idempotent; // 非幂等请求禁止重试
if (!canRetry) {
return Promise.reject(error); // 不可重试,直接拒绝
}
// 准备重试:递增重试计数
config._retryCount! += 1;
const currentRetryCount = config._retryCount!;
// 计算重试延迟
const delayMs = retryOptions.retryDelay(currentRetryCount);
console.log(`请求 ${config.url} 第 ${currentRetryCount} 次重试,延迟 ${delayMs}ms`);
// 延迟后重试
await delay(delayMs);
return instance(config); // 重试请求
}
);
return instance;
};
// 实例化带重试的Axios(全局单例)
export const retryAxios = createRetryAxios();
typescript
// src/api/productApi.ts - 商品接口(使用重试机制)
import { retryAxios } from '@/utils/axiosRetry';
import { RetryAxiosRequestConfig } from '@/types/retry';
import type { ProductListParams, ProductListResult, ProductDetail } from '@/types/product';
/**
* 获取商品列表(GET请求,默认幂等,使用全局重试配置)
*/
export const getProductList = (params: ProductListParams) => {
return retryAxios.get<ProductListResult>('/api/product/list', { params });
};
/**
* 获取商品详情(GET请求,自定义重试配置:最多重试2次)
*/
export const getProductDetail = (id: number) => {
const config: RetryAxiosRequestConfig = {
url: `/api/product/detail/${id}`,
method: 'get',
retry: {
maxRetries: 2 // 覆盖全局的3次,最多重试2次
}
};
return retryAxios(config).then(res => res.data);
};
vue
<!-- src/views/ProductListPage.vue - 商品列表页(使用带重试的接口) -->
<template>
<div class="product-list-page">
<h1>商品列表</h1>
<!-- 加载状态 -->
<div class="loading" v-if="isLoading">
加载商品列表中...
</div>
<!-- 错误提示 -->
<div class="error" v-if="errorMessage">
⚠️ {{ errorMessage }}
<button @click="loadProducts">重试</button>
</div>
<!-- 商品列表 -->
<div class="product-grid" v-if="products.length">
<ProductItem
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { getProductList } from '@/api/productApi';
import ProductItem from '@/components/ProductItem.vue';
import type { ProductListParams, ProductListItem } from '@/types/product';
// 状态管理
const products = ref<ProductListItem[]>([]);
const isLoading = ref(true);
const errorMessage = ref('');
/**
* 加载商品列表(依赖接口自动重试)
*/
const loadProducts = async () => {
isLoading.value = true;
errorMessage.value = '';
try {
const params: ProductListParams = { page: 1, size: 10 };
const response = await getProductList(params);
products.value = response.data.list;
} catch (error: any) {
// 所有重试失败后,提示用户
errorMessage.value = error.message || '商品列表加载失败,请稍后重试';
console.error('商品列表加载失败(已重试):', error);
} finally {
isLoading.value = false;
}
};
// 页面挂载时加载商品
onMounted(() => {
loadProducts();
});
</script>
<style scoped>
.product-list-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
text-align: center;
padding: 20px;
background: #fff3f3;
border: 1px solid #ffcccc;
border-radius: 4px;
color: #e53e3e;
}
.error button {
margin-left: 10px;
padding: 4px 12px;
background: #4299e1;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-top: 20px;
}
</style>
3.1.3 代码关键优化点
- 指数退避+随机抖动 :
retryDelay函数通过Math.pow(2, retryCount)实现指数增长,叠加随机抖动避免多个客户端同时重试导致的请求峰值; - 分层配置机制 :支持"全局默认配置"+"接口级自定义配置"(如
getProductDetail覆盖最大重试次数),灵活适配不同接口需求; - 幂等性默认规则:自动标记 GET 请求为幂等(可安全重试),POST 请求为非幂等(默认禁止重试),降低误重试风险;
- 重试状态跟踪 :通过
_retryCount跟踪已重试次数,避免超出maxRetries限制,防止无限重试; - 错误边界处理:所有重试失败后,明确提示用户并提供手动重试按钮,平衡自动重试与用户干预。
3.2 方案二:基于错误类型的智能重试(业务错误码适配)
3.2.1 需求场景
业务接口返回自定义错误码(如 { code: number, message: string }),需对"临时业务错误"(如 code: 9999 表示缓存过期)进行重试,对"永久业务错误"(如 code: 1001 表示库存不足)不重试。
3.2.2 完整代码实现
扩展错误判断逻辑,支持业务错误码的可重试性校验。
typescript
// src/types/businessError.ts - 业务错误类型定义
/**
* 后端返回的业务响应格式
*/
export interface BusinessResponse<T = any> {
code: number; // 业务状态码:0表示成功,非0表示失败
message: string; // 错误信息
data: T; // 业务数据
}
/**
* 业务错误类型(包含业务状态码)
*/
export class BusinessError extends Error {
code: number; // 业务错误码
response?: any; // 原始响应
constructor(message: string, code: number, response?: any) {
super(message);
this.name = 'BusinessError';
this.code = code;
this.response = response;
}
}
typescript
// src/utils/businessRetryHelper.ts - 业务错误重试工具
import { AxiosError } from 'axios';
import { BusinessError, BusinessResponse } from '@/types/businessError';
import { RetryOptions } from '@/types/retry';
/**
* 业务错误可重试判断函数
* @param error Axios错误
* @returns 是否可重试
*/
export const isBusinessRetryable = (error: AxiosError): boolean => {
// 1. 先判断网络/服务器错误(复用基础逻辑)
if (!error.response) {
return true; // 网络错误可重试
}
const status = error.response.status;
if (status === 503 || status === 504) {
return true; // 服务器临时错误可重试
}
// 2. 处理业务错误(200状态码但业务code非0)
if (status === 200) {
const data = error.response.data as BusinessResponse;
if (data.code) {
// 业务错误码判断:9999(缓存过期)、9998(临时限流)可重试
return [9999, 9998].includes(data.code);
}
}
// 其他错误不可重试
return false;
};
/**
* 业务接口专用重试配置
*/
export const businessRetryOptions: RetryOptions = {
maxRetries: 2, // 业务接口重试次数减少(避免影响用户体验)
retryDelay: (retryCount) => {
// 业务错误重试间隔更短(1s→1.5s,用户感知更弱)
const delays = [1000, 1500, 2000];
return delays[retryCount - 1] || 2000;
},
isRetryable: isBusinessRetryable // 使用业务错误判断
};
/**
* 业务响应拦截器:将业务错误转换为BusinessError
* @param response Axios响应
* @returns 处理后的响应
*/
export const businessResponseInterceptor = (response: any) => {
const data = response.data as BusinessResponse;
if (data.code !== 0) {
// 业务错误:抛出BusinessError
throw new BusinessError(data.message, data.code, response);
}
return response; // 业务成功:返回数据
};
typescript
// src/utils/businessAxios.ts - 业务接口专用Axios(带智能重试)
import { createRetryAxios } from '@/utils/axiosRetry';
import { businessRetryOptions, businessResponseInterceptor } from '@/utils/businessRetryHelper';
// 创建业务接口专用Axios(使用业务重试配置)
export const businessAxios = createRetryAxios(businessRetryOptions);
// 添加业务响应拦截器(转换业务错误)
businessAxios.interceptors.response.use(
businessResponseInterceptor, // 成功响应:检查业务code
(error) => Promise.reject(error) // 失败响应:交给重试逻辑处理
);
typescript
// src/api/cartApi.ts - 购物车接口(业务错误重试)
import { businessAxios } from '@/utils/businessAxios';
import { RetryAxiosRequestConfig } from '@/types/retry';
import type { CartItem, AddToCartParams } from '@/types/cart';
/**
* 获取购物车列表(业务接口,支持缓存过期重试)
*/
export const getCartList = () => {
return businessAxios.get<BusinessResponse<CartItem[]>>('/api/cart/list')
.then(res => res.data.data);
};
/**
* 添加商品到购物车(POST请求,手动标记为幂等)
* 注意:需后端通过商品ID+用户ID确保幂等性
*/
export const addToCart = (params: AddToCartParams) => {
const config: RetryAxiosRequestConfig = {
url: '/api/cart/add',
method: 'post',
data: params,
_idempotent: true, // 手动标记为幂等(后端已实现去重)
retry: {
maxRetries: 1 // 购物车操作重试1次即可
}
};
return businessAxios(config)
.then(res => res.data.data);
};
vue
<!-- src/components/CartItemAdd.vue - 添加购物车组件(业务重试) -->
<template>
<button
class="add-to-cart-btn"
@click="handleAddToCart"
:disabled="isLoading"
>
{{ isLoading ? '添加中...' : '加入购物车' }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { addToCart } from '@/api/cartApi';
import { AddToCartParams } from '@/types/cart';
import { BusinessError } from '@/types/businessError';
// 接收商品参数
const props = defineProps<{
productId: number;
quantity: number;
}>();
const isLoading = ref(false);
/**
* 处理添加购物车(支持业务错误重试)
*/
const handleAddToCart = async () => {
isLoading.value = true;
try {
const params: AddToCartParams = {
productId: props.productId,
quantity: props.quantity
};
await addToCart(params);
alert('添加购物车成功!');
} catch (error) {
// 区分业务错误与其他错误
if (error instanceof BusinessError) {
// 业务错误:根据code提示(如1001=库存不足,不可重试)
alert(`添加失败:${error.message}`);
} else {
// 其他错误(网络/服务器):已重试后仍失败
alert('网络异常,添加购物车失败,请稍后重试');
}
console.error('添加购物车失败:', error);
} finally {
isLoading.value = false;
}
};
</script>
<style scoped>
.add-to-cart-btn {
padding: 8px 16px;
background: #48bb78;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.add-to-cart-btn:disabled {
background: #a0ecc0;
cursor: not-allowed;
}
</style>
3.2.3 代码关键优化点
- 业务错误码适配 :通过
isBusinessRetryable函数识别可重试的业务错误(如缓存过期),仅对这类错误重试,避免无效操作; - 业务专用重试配置:业务接口重试次数更少(2次)、间隔更短(1s→1.5s),减少用户等待感,平衡重试效果与体验;
- 显式幂等标记 :POST请求(如
addToCart)通过_idempotent: true手动标记幂等性,需后端配合实现去重,确保重试安全; - 错误类型区分 :通过
BusinessError类封装业务错误,前端可根据错误码提供针对性提示(如库存不足直接提示,无需重试)。
3.3 方案三:带幂等性校验的关键接口重试(订单提交场景)
3.3.1 需求场景
订单提交接口(POST)需支持重试:用户网络波动导致提交失败时,自动重试1次,但必须确保"重试不会创建重复订单"。后端通过"幂等ID"实现去重,前端需生成并传递唯一ID。
3.3.2 完整代码实现
结合幂等ID生成与重试机制,确保关键操作重试安全。
typescript
// src/utils/idempotencyHelper.ts - 幂等ID生成工具
/**
* 生成幂等ID(用于关键操作去重)
* 格式:prefix + timestamp + random + suffix
*/
export const generateIdempotencyId = (prefix: string = 'req'): string => {
const timestamp = Date.now().toString(36); // 时间戳(36进制缩短长度)
const random = Math.random().toString(36).substring(2, 8); // 随机字符串
return `${prefix}_${timestamp}_${random}`;
};
/**
* 存储幂等ID到localStorage(避免页面刷新后丢失)
* @param key 存储键
* @param id 幂等ID
*/
export const saveIdempotencyId = (key: string, id: string): void => {
try {
localStorage.setItem(`idempotency_${key}`, id);
} catch (error) {
console.warn('存储幂等ID失败:', error);
}
};
/**
* 获取存储的幂等ID(用于重试时复用)
* @param key 存储键
* @returns 幂等ID或null
*/
export const getIdempotencyId = (key: string): string | null => {
try {
return localStorage.getItem(`idempotency_${key}`);
} catch (error) {
console.warn('获取幂等ID失败:', error);
return null;
}
};
typescript
// src/api/orderApi.ts - 订单接口(带幂等重试)
import { businessAxios } from '@/utils/businessAxios';
import { RetryAxiosRequestConfig } from '@/types/retry';
import {
generateIdempotencyId,
saveIdempotencyId,
getIdempotencyId
} from '@/utils/idempotencyHelper';
import type { OrderParams, OrderResult } from '@/types/order';
/**
* 提交订单(关键接口,带幂等重试)
* @param params 订单参数
* @param idempotencyKey 幂等键(用于复用ID)
* @returns 订单结果
*/
export const submitOrder = async (
params: OrderParams,
idempotencyKey: string = 'order_submit'
): Promise<OrderResult> => {
// 1. 获取或生成幂等ID(重试时复用同一ID,确保幂等)
let idempotencyId = getIdempotencyId(idempotencyKey);
if (!idempotencyId) {
idempotencyId = generateIdempotencyId('order');
saveIdempotencyId(idempotencyKey, idempotencyId);
}
// 2. 构造请求配置(标记幂等,限制重试1次)
const config: RetryAxiosRequestConfig = {
url: '/api/order/submit',
method: 'post',
data: {
...params,
idempotencyId // 传递幂等ID给后端
},
_idempotent: true, // 标记为幂等(允许重试)
retry: {
maxRetries: 1, // 关键操作仅重试1次
retryDelay: () => 1000 // 1秒后重试
},
headers: {
'X-Idempotency-Key': idempotencyId // 额外在请求头传递幂等ID
}
};
try {
const response = await businessAxios(config);
// 订单提交成功后,清除存储的幂等ID(避免重复使用)
localStorage.removeItem(`idempotency_${idempotencyKey}`);
return response.data.data;
} catch (error) {
// 失败不清除ID,方便用户手动重试时复用
throw error;
}
};
vue
<!-- src/views/CheckoutPage.vue - 结账页面(订单提交重试) -->
<template>
<div class="checkout-page">
<h1>确认订单</h1>
<!-- 订单信息展示 -->
<OrderSummary :items="orderItems" />
<!-- 提交按钮 -->
<button
class="submit-btn"
@click="handleSubmitOrder"
:disabled="isSubmitting"
>
{{ isSubmitting ? '提交中...' : `提交订单(¥${totalAmount.toFixed(2)})` }}
</button>
<!-- 错误提示 -->
<div class="error" v-if="errorMessage">
⚠️ {{ errorMessage }}
<button @click="handleSubmitOrder">手动重试</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { submitOrder } from '@/api/orderApi';
import OrderSummary from '@/components/OrderSummary.vue';
import type { OrderParams, OrderItem } from '@/types/order';
import { BusinessError } from '@/types/businessError';
// 订单数据(假设从购物车传入)
const props = defineProps<{
items: OrderItem[];
}>();
// 状态管理
const isSubmitting = ref(false);
const errorMessage = ref('');
const router = useRouter();
// 计算总金额
const totalAmount = computed(() => {
return props.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
/**
* 处理订单提交(带幂等重试)
*/
const handleSubmitOrder = async () => {
isSubmitting.value = true;
errorMessage.value = '';
try {
// 构造订单参数
const params: OrderParams = {
productIds: props.items.map(item => item.productId),
quantities: props.items.map(item => item.quantity),
addressId: 123, // 假设选中的地址ID
paymentType: 'alipay'
};
// 提交订单(自动重试1次)
const orderResult = await submitOrder(params);
// 提交成功,跳转到支付页面
router.push(`/order/pay/${orderResult.orderId}`);
} catch (error) {
// 处理错误(区分业务错误与网络错误)
if (error instanceof BusinessError) {
// 业务错误(如库存不足、余额不足):不重试,直接提示
errorMessage.value = `提交失败:${error.message}`;
} else {
// 网络/服务器错误:已自动重试1次,仍失败则提示手动重试
errorMessage.value = '网络异常,订单提交失败,请点击"手动重试"';
}
console.error('订单提交失败:', error);
} finally {
isSubmitting.value = false;
}
};
</script>
<style scoped>
.checkout-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.submit-btn {
margin-top: 20px;
padding: 12px 24px;
background: #ff4400;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
width: 100%;
}
.submit-btn:disabled {
background: #ff9966;
cursor: not-allowed;
}
.error {
margin-top: 10px;
padding: 12px;
background: #fff3f3;
border: 1px solid #ffcccc;
border-radius: 4px;
color: #e53e3e;
text-align: center;
}
.error button {
margin-left: 10px;
padding: 4px 12px;
background: #4299e1;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
3.3.3 代码关键优化点
- 幂等ID机制 :通过
generateIdempotencyId生成唯一ID,重试时复用同一ID,后端根据该ID去重,确保"多次提交=一次提交",解决POST请求重试的重复操作问题; - ID持久化存储:幂等ID存储在localStorage,页面刷新后仍可复用,避免用户刷新页面后重试导致的ID变化;
- 关键操作重试限制:订单提交仅重试1次,减少重复操作风险,同时缩短重试间隔(1秒),降低用户等待感;
- 双重传递幂等ID :既在请求体
data中传递,也在请求头X-Idempotency-Key中传递,确保后端能可靠获取ID(应对不同解析逻辑); - 成功后清除ID:订单提交成功后立即清除存储的幂等ID,避免用户再次提交时复用旧ID(导致重复订单)。
3.4 方案四:带熔断机制的重试策略(避免服务雪崩)
3.4.1 需求场景
当后端服务持续返回错误(如500错误率超过50%),继续重试会加重服务器负担,甚至引发"服务雪崩"。需实现熔断机制:当错误率超过阈值时,暂停重试一段时间(如30秒),避免无效请求。
3.4.2 完整代码实现
结合熔断器模式(Circuit Breaker),动态调整重试策略。
typescript
// src/types/circuitBreaker.ts - 熔断机制类型定义
/**
* 熔断器状态
*/
export enum CircuitState {
CLOSED = 'closed', // 闭合:正常重试
OPEN = 'open', // 打开:暂停重试
HALF_OPEN = 'half_open' // 半开:允许部分请求试探
}
/**
* 熔断器配置
*/
export interface CircuitBreakerOptions {
failureThreshold: number; // 失败阈值(错误率百分比,如50=50%)
failureCountThreshold: number; // 最小失败次数(如5次失败后才判断)
resetTimeout: number; // 熔断器打开后,多久进入半开状态(毫秒,如30000=30秒)
halfOpenAttempts: number; // 半开状态允许的试探请求数(如2次)
}
/**
* 熔断器状态记录
*/
export interface CircuitStateRecord {
state: CircuitState;
failureCount: number; // 失败计数
successCount: number; // 成功计数
totalCount: number; // 总请求计数
lastFailureTime: number; // 最后一次失败时间
openUntil: number; // 熔断器打开至何时(时间戳)
}
typescript
// src/utils/circuitBreaker.ts - 熔断器实现
import { CircuitState, CircuitBreakerOptions, CircuitStateRecord } from '@/types/circuitBreaker';
/**
* 熔断器类(用于控制重试策略)
*/
export class CircuitBreaker {
private options: CircuitBreakerOptions;
private stateRecord: CircuitStateRecord;
constructor(options: Partial<CircuitBreakerOptions> = {}) {
// 默认配置:错误率>50%且至少5次失败 → 打开30秒 → 半开允许2次试探
this.options = {
failureThreshold: 50,
failureCountThreshold: 5,
resetTimeout: 30 * 1000,
halfOpenAttempts: 2,
...options
};
// 初始化状态
this.stateRecord = {
state: CircuitState.CLOSED,
failureCount: 0,
successCount: 0,
totalCount: 0,
lastFailureTime: 0,
openUntil: 0
};
}
/**
* 获取当前熔断器状态
*/
get state(): CircuitState {
// 检查打开状态是否已过期(进入半开)
if (
this.stateRecord.state === CircuitState.OPEN &&
Date.now() > this.stateRecord.openUntil
) {
this.stateRecord.state = CircuitState.HALF_OPEN;
this.stateRecord.failureCount = 0;
this.stateRecord.successCount = 0;
console.log('熔断器从OPEN转为HALF_OPEN');
}
return this.stateRecord.state;
}
/**
* 判断是否允许请求(根据熔断器状态)
*/
isAllowed(): boolean {
switch (this.state) {
case CircuitState.CLOSED:
return true; // 闭合状态:允许请求
case CircuitState.OPEN:
return false; // 打开状态:禁止请求
case CircuitState.HALF_OPEN:
// 半开状态:允许有限次数的试探请求
return this.stateRecord.totalCount < this.options.halfOpenAttempts;
default:
return true;
}
}
/**
* 记录请求成功
*/
recordSuccess(): void {
this.stateRecord.totalCount++;
this.stateRecord.successCount++;
if (this.state === CircuitState.HALF_OPEN) {
// 半开状态:成功次数达标 → 转为闭合
if (this.stateRecord.successCount >= this.options.halfOpenAttempts) {
this.stateRecord.state = CircuitState.CLOSED;
this.resetCounts();
console.log('熔断器从HALF_OPEN转为CLOSED(试探成功)');
}
} else {
// 闭合状态:成功则减少失败计数
this.stateRecord.failureCount = Math.max(0, this.stateRecord.failureCount - 1);
}
}
/**
* 记录请求失败
*/
recordFailure(): void {
this.stateRecord.totalCount++;
this.stateRecord.failureCount++;
this.stateRecord.lastFailureTime = Date.now();
if (this.state === CircuitState.HALF_OPEN) {
// 半开状态:任何失败 → 转为打开
this.setStateToOpen();
console.log('熔断器从HALF_OPEN转为OPEN(试探失败)');
} else if (this.state === CircuitState.CLOSED) {
// 闭合状态:检查是否达到失败阈值
this.checkFailureThreshold();
}
}
/**
* 检查是否达到失败阈值,若达到则转为打开状态
*/
private checkFailureThreshold(): void {
// 未达到最小失败次数,不判断
if (this.stateRecord.failureCount < this.options.failureCountThreshold) {
return;
}
// 计算错误率(失败次数/总次数)
const failureRate = (this.stateRecord.failureCount / this.stateRecord.totalCount) * 100;
// 错误率超过阈值 → 转为打开状态
if (failureRate >= this.options.failureThreshold) {
this.setStateToOpen();
console.log(`熔断器从CLOSED转为OPEN(错误率${failureRate.toFixed(1)}%)`);
}
}
/**
* 设置熔断器为打开状态
*/
private setStateToOpen(): void {
this.stateRecord.state = CircuitState.OPEN;
this.stateRecord.openUntil = Date.now() + this.options.resetTimeout;
this.resetCounts();
}
/**
* 重置计数(状态转换时)
*/
private resetCounts(): void {
this.stateRecord.failureCount = 0;
this.stateRecord.successCount = 0;
this.stateRecord.totalCount = 0;
}
}
typescript
// src/utils/axiosWithCircuitBreaker.ts - 带熔断机制的Axios
import { createRetryAxios } from '@/utils/axiosRetry';
import { CircuitBreaker } from '@/utils/circuitBreaker';
import { RetryAxiosRequestConfig } from '@/types/retry';
import { businessResponseInterceptor } from '@/utils/businessRetryHelper';
// 创建全局熔断器(针对推荐商品接口)
const recommendCircuitBreaker = new CircuitBreaker({
failureThreshold: 50, // 错误率>50%触发熔断
resetTimeout: 20 * 1000 // 熔断后20秒尝试恢复
});
// 创建带熔断的Axios实例
export const circuitBreakerAxios = createRetryAxios({
maxRetries: 2,
// 重试前检查熔断器状态
isRetryable: (error) => {
// 只有熔断器允许请求时,才重试
return recommendCircuitBreaker.isAllowed() && defaultRetryOptions.isRetryable(error);
}
});
// 添加响应拦截器:记录成功/失败,更新熔断器状态
circuitBreakerAxios.interceptors.response.use(
(response) => {
// 成功响应:记录成功
if (response.config.url?.includes('/api/product/recommend')) {
recommendCircuitBreaker.recordSuccess();
}
return businessResponseInterceptor(response);
},
(error) => {
// 失败响应:记录失败
if (error.config?.url?.includes('/api/product/recommend')) {
recommendCircuitBreaker.recordFailure();
}
return Promise.reject(error);
}
);
typescript
// src/api/recommendApi.ts - 推荐商品接口(带熔断重试)
import { circuitBreakerAxios } from '@/utils/axiosWithCircuitBreaker';
import type { Product } from '@/types/product';
/**
* 获取推荐商品(带熔断机制,避免服务雪崩)
*/
export const getRecommendProducts = () => {
return circuitBreakerAxios.get<BusinessResponse<Product[]>>('/api/product/recommend')
.then(res => res.data.data)
.catch(error => {
// 熔断状态下返回降级数据(本地缓存)
if (!recommendCircuitBreaker.isAllowed()) {
console.log('推荐商品接口已熔断,使用降级数据');
return getRecommendFallbackData(); // 返回本地缓存的推荐数据
}
throw error;
});
};
/**
* 推荐商品降级数据(本地缓存)
*/
const getRecommendFallbackData = (): Product[] => {
try {
const fallback = localStorage.getItem('recommend_fallback');
return fallback ? JSON.parse(fallback) : [];
} catch (error) {
return [];
}
};
vue
<!-- src/components/RecommendSection.vue - 推荐商品组件(熔断降级) -->
<template>
<div class="recommend-section">
<h2>为你推荐</h2>
<!-- 熔断提示 -->
<div class="circuit-alert" v-if="isCircuitOpen">
⚠️ 推荐服务暂时不稳定,展示历史推荐
</div>
<!-- 推荐商品列表 -->
<ProductGrid :products="recommendProducts" />
<!-- 加载状态 -->
<div class="loading" v-if="isLoading">
加载推荐商品中...
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { getRecommendProducts } from '@/api/recommendApi';
import { recommendCircuitBreaker } from '@/utils/axiosWithCircuitBreaker';
import ProductGrid from '@/components/ProductGrid.vue';
import type { Product } from '@/types/product';
// 状态管理
const recommendProducts = ref<Product[]>([]);
const isLoading = ref(true);
// 计算熔断器是否打开
const isCircuitOpen = computed(() => {
return recommendCircuitBreaker.state === 'open';
});
/**
* 加载推荐商品(带熔断降级)
*/
const loadRecommendations = async () => {
isLoading.value = true;
try {
const data = await getRecommendProducts();
recommendProducts.value = data;
// 缓存数据作为降级备用
localStorage.setItem('recommend_fallback', JSON.stringify(data));
} catch (error) {
console.error('推荐商品加载失败:', error);
// 失败时使用降级数据
const fallback = localStorage.getItem('recommend_fallback');
if (fallback) {
recommendProducts.value = JSON.parse(fallback);
}
} finally {
isLoading.value = false;
}
};
// 组件挂载时加载推荐商品
onMounted(() => {
loadRecommendations();
});
</script>
<style scoped>
.recommend-section {
margin: 30px 0;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
}
.circuit-alert {
padding: 8px 12px;
background: #fff3cd;
border: 1px solid #ffeeba;
border-radius: 4px;
color: #856404;
margin-bottom: 16px;
font-size: 14px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
</style>
3.4.3 代码关键优化点
- 熔断器三态管理 :实现
CLOSED(正常重试)→OPEN(暂停重试)→HALF_OPEN(试探恢复)的状态转换,避免服务持续错误时的无效重试; - 动态阈值控制:当错误率超过50%且失败次数≥5次时触发熔断,20秒后进入半开状态,允许2次试探请求,成功则恢复,失败则继续熔断;
- 熔断时降级策略 :熔断器打开时,返回本地缓存的降级数据(
getRecommendFallbackData),确保用户仍能看到内容,而非空白或错误提示; - 接口级熔断隔离:为推荐商品接口单独创建熔断器,避免该接口熔断影响其他接口(如用户信息、订单提交),实现"故障隔离";
- 状态持久化感知 :通过
isCircuitOpen计算属性实时感知熔断状态,向用户展示友好提示(如"推荐服务暂时不稳定")。
四、实践反例警示(Vue3 场景)
错误重试策略若设计不当,会导致"无效重试浪费资源""重复操作引发业务问题""服务雪崩"等严重后果。以下结合Vue3实战场景,分析4个典型反例的错误原因与修复方案。
4.1 反例1:不区分错误类型,盲目重试所有失败
4.1.1 错误代码(对400/401错误也重试)
typescript
// src/utils/wrongBlindRetry.ts - 错误的盲目重试
import axios from 'axios';
const wrongAxios = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL
});
// 错误:不判断错误类型,所有失败都重试3次
wrongAxios.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
if (!config._retryCount) {
config._retryCount = 0;
}
// 错误:对400(参数错误)、401(未授权)等也重试
if (config._retryCount < 3) {
config._retryCount++;
await new Promise(resolve => setTimeout(resolve, 1000));
return wrongAxios(config);
}
return Promise.reject(error);
}
);
// 登录接口(401错误会被盲目重试)
export const login = (params: { username: string; password: string }) => {
return wrongAxios.post('/api/auth/login', params);
};
4.1.2 错误原因
- 无效重试浪费资源:对400(参数错误)、401(未授权)等永久性错误重试,不会改变结果,只会增加网络请求和服务器负担;
- 加重服务器压力:例如用户输入错误密码导致401,盲目重试3次会向服务器发送3次无效请求,浪费资源;
- 延长用户等待时间:每次重试都有延迟(如1秒),3次重试会让用户多等待3秒,却无法解决问题,体验下降。
4.1.3 修复方案
- 按错误类型过滤可重试错误:仅对网络错误、503、504等临时性错误重试;
- 排除客户端错误:明确排除400、401、403、404等客户端错误;
- 业务错误单独判断:对200状态码但业务错误的情况,根据错误码决定是否重试。
修复后的核心代码:
typescript
// src/utils/correctRetryByType.ts - 按错误类型重试
import axios from 'axios';
const correctAxios = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL
});
correctAxios.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
if (!config._retryCount) {
config._retryCount = 0;
}
// 修复1:判断错误是否可重试
const isRetryable = () => {
if (!error.response) return true; // 网络错误可重试
const status = error.response.status;
// 仅503、504可重试,排除4xx错误
return status === 503 || status === 504;
};
if (config._retryCount < 3 && isRetryable()) {
config._retryCount++;
await new Promise(resolve => setTimeout(resolve, 1000));
return correctAxios(config);
}
return Promise.reject(error);
}
);
4.2 反例2:对非幂等POST请求重试,导致重复操作
4.2.1 错误代码(订单提交无幂等性重试)
typescript
// src/api/wrongOrderApi.ts - 错误的订单重试
import { retryAxios } from '@/utils/axiosRetry';
// 错误:对非幂等POST请求允许重试,无去重机制
export const wrongSubmitOrder = (params: any) => {
return retryAxios.post('/api/order/submit', params, {
retry: { maxRetries: 2 },
_idempotent: true // 错误标记:实际后端未实现幂等
});
};
vue
<!-- src/components/WrongOrderSubmit.vue - 错误的订单提交 -->
<template>
<button @click="submit">提交订单(错误重试)</button>
</template>
<script setup lang="ts">
import { wrongSubmitOrder } from '@/api/wrongOrderApi';
const submit = async () => {
try {
// 错误:网络波动时会重试2次,后端无去重→重复订单
await wrongSubmitOrder({ productId: 1, quantity: 1 });
} catch (error) {
console.error(error);
}
};
</script>
4.2.2 错误原因
- 重复创建资源:POST请求通常用于创建资源(如订单),非幂等性场景下重试会导致重复创建(用户收到多个订单,需人工取消);
- 错误标记幂等性 :前端错误标记
_idempotent: true,但后端未实现幂等逻辑(如无去重ID),导致重试安全校验失效; - 业务数据不一致:重复订单会引发库存扣减错误、支付金额异常等连锁问题,增加客服成本。
4.2.3 修复方案
- 后端实现幂等性:通过幂等ID(如前端生成的唯一ID)确保多次请求仅创建一次资源;
- 前端传递幂等ID:每次请求携带唯一ID,重试时复用该ID;
- 限制重试次数:关键POST接口最多重试1次,降低重复风险;
- 非幂等接口禁止重试 :未实现幂等的接口标记
_idempotent: false,禁止重试。
修复后的核心代码:
typescript
// src/api/correctOrderApi.ts - 正确的订单重试
import { retryAxios } from '@/utils/axiosRetry';
import { generateIdempotencyId } from '@/utils/idempotencyHelper';
export const correctSubmitOrder = (params: any) => {
const idempotencyId = generateIdempotencyId('order');
return retryAxios.post('/api/order/submit', {
...params,
idempotencyId // 传递幂等ID
}, {
retry: { maxRetries: 1 }, // 修复2:最多重试1次
_idempotent: true // 修复3:确保后端已实现幂等
});
};
4.3 反例3:重试间隔固定且过短,引发"重试风暴"
4.3.1 错误代码(固定100ms短间隔重试)
typescript
// src/utils/wrongRetryInterval.ts - 错误的重试间隔
import { createRetryAxios } from '@/utils/axiosRetry';
// 错误:固定100ms短间隔,大量请求同时重试
export const wrongIntervalAxios = createRetryAxios({
maxRetries: 5,
retryDelay: () => 100 // 所有重试都间隔100ms
});
// 首页多个接口同时使用该Axios实例
export const getHomeData = () => wrongIntervalAxios.get('/api/home/data');
export const getBanner = () => wrongIntervalAxios.get('/api/home/banner');
export const getActivity = () => wrongIntervalAxios.get('/api/home/activity');
4.3.2 错误原因
- 请求集中爆发:固定短间隔(如100ms)导致所有失败的请求在同一时间点重试,形成"重试风暴",瞬间压垮服务器;
- 服务器恶性循环:服务器本就过载,集中的重试请求进一步加剧负担,导致更多请求失败,形成"失败→重试→更失败"的恶性循环;
- 网络带宽耗尽:短间隔重试导致大量请求在短时间内发送,占用带宽,影响其他正常请求。
4.3.3 修复方案
- 使用指数退避间隔:重试间隔随次数增长(1s→2s→4s),分散请求压力;
- 添加随机抖动:在指数退避基础上增加随机值,避免多个客户端同时重试;
- 限制最大重试次数:减少重试次数(如3次以内),降低总请求量。
修复后的核心代码:
typescript
// src/utils/correctRetryInterval.ts - 正确的重试间隔
import { createRetryAxios } from '@/utils/axiosRetry';
// 修复:指数退避+随机抖动
export const correctIntervalAxios = createRetryAxios({
maxRetries: 3,
retryDelay: (retryCount) => {
const base = Math.pow(2, retryCount) * 1000; // 1s→2s→4s
const jitter = Math.random() * 500; // ±250ms随机抖动
return base - jitter;
}
});
4.4 反例4:无熔断机制,服务故障时持续重试
4.4.1 错误代码(服务宕机仍持续重试)
typescript
// src/utils/wrongNoCircuitBreaker.ts - 无熔断机制
import { createRetryAxios } from '@/utils/axiosRetry';
// 错误:无熔断机制,后端宕机时仍无限重试
export const noCircuitAxios = createRetryAxios({
maxRetries: 5, // 重试5次
isRetryable: () => true // 所有错误都重试
});
// 商品搜索接口(后端宕机时会被反复调用)
export const searchProducts = (keyword: string) => {
return noCircuitAxios.get('/api/product/search', { params: { keyword } });
};
vue
<!-- src/views/WrongSearchPage.vue - 无熔断的搜索页面 -->
<template>
<input v-model="keyword" @input="handleSearch" placeholder="搜索商品">
<ProductList :products="products" />
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { searchProducts } from '@/utils/wrongNoCircuitBreaker';
const keyword = ref('');
const products = ref([]);
// 错误:用户输入时频繁触发搜索,后端宕机时会引发大量重试
const handleSearch = async () => {
try {
const res = await searchProducts(keyword.value);
products.value = res.data.data;
} catch (error) {
console.error(error);
}
};
// 实时搜索(用户输入即触发)
watch(keyword, handleSearch);
</script>
4.4.2 错误原因
- 服务雪崩风险:后端服务宕机(如返回500)时,前端仍按最大次数重试,大量请求持续发送,导致服务无法恢复;
- 前端资源耗尽:浏览器处理大量重试请求会占用CPU和内存,导致页面卡顿甚至崩溃;
- 用户体验极差:用户输入时实时搜索,每次输入都会触发请求+重试,页面长时间无响应,甚至出现假死。
4.4.3 修复方案
- 添加熔断机制:服务错误率过高时暂停重试,给服务恢复时间;
- 搜索节流:用户输入时添加节流(如300ms触发一次),减少请求频率;
- 失败后降级:熔断时返回本地缓存结果或提示"服务暂时 unavailable";
- 限制重试次数:关键接口重试次数≤2次,避免过度请求。
修复后的核心代码:
typescript
// src/utils/correctWithCircuitBreaker.ts - 带熔断的搜索
import { circuitBreakerAxios } from '@/utils/axiosWithCircuitBreaker';
import { throttle } from 'lodash'; // 引入节流函数
// 修复1:添加节流(300ms一次)
export const throttledSearch = throttle(async (keyword: string) => {
try {
// 修复2:使用带熔断的Axios
const res = await circuitBreakerAxios.get('/api/product/search', {
params: { keyword }
});
return res.data.data;
} catch (error) {
// 修复3:熔断时返回缓存数据
const cache = localStorage.getItem(`search_cache_${keyword}`);
return cache ? JSON.parse(cache) : [];
}
}, 300);
五、代码评审要点聚焦(Vue3 + TypeScript)
错误重试策略的评审需围绕"重试有效性 ""安全性 ""资源可控性 ""用户体验"四大维度,结合Vue3生态特性制定评审要点,确保重试策略既提升成功率,又不引发新问题。
5.1 重试有效性评审
5.1.1 核心检查点
-
错误类型过滤准确性
- 检查
isRetryable函数是否正确区分可重试错误(网络错误、503、504)与不可重试错误(400、401、404); - 检查业务错误是否通过错误码(如
code: 9999)精准判断可重试性,无"对库存不足等永久错误重试"的情况; - 示例:评审时模拟400错误,验证是否会触发重试(正确结果:不重试)。
- 检查
-
重试次数与间隔合理性
- 检查
maxRetries是否根据接口重要性调整(普通接口2-3次,关键接口1-2次),无"所有接口都重试5次"的情况; - 检查重试间隔是否采用"指数退避+随机抖动",避免固定短间隔(如100ms)导致的请求集中;
- 工具支持:通过调试工具查看重试间隔,验证是否符合预期(如1s→2s→4s)。
- 检查
-
熔断机制有效性
- 检查熔断器是否正确实现三态转换(CLOSED→OPEN→HALF_OPEN),错误率超过阈值时是否能暂停重试;
- 检查半开状态是否仅允许有限次数的试探请求(如2次),避免一次性发送大量请求;
- 检查熔断时是否有降级方案(如返回缓存数据),而非直接抛出错误。
5.2 重试安全性评审
5.2.1 核心检查点
-
幂等性保障措施
- 检查POST请求重试是否满足"幂等性",是否通过幂等ID、后端去重等机制确保"多次请求=一次请求";
- 检查非幂等接口是否被标记为
_idempotent: false,禁止重试; - 检查幂等ID是否唯一且持久化(如存储在localStorage),避免页面刷新后ID变化导致去重失效。
-
重复请求防护
- 检查同一接口的并发请求是否被限制(如通过防抖/节流),避免用户快速操作导致的重复请求+重试;
- 检查重试逻辑是否会导致"旧请求覆盖新请求"(如分页查询时,页1的重试覆盖页2的结果);
- 示例:评审时快速点击"提交订单"按钮,验证是否会发起多个请求(正确结果:仅1个请求+重试)。
-
业务数据一致性
- 检查重试是否会导致业务数据异常(如重复扣减库存、重复积分);
- 检查关键操作(如支付、下单)的重试是否有日志记录,便于问题排查;
- 检查重试失败后是否有数据回滚机制(如本地临时状态清除)。
5.3 资源可控性评审
5.3.1 核心检查点
-
请求资源占用
- 检查重试是否会导致浏览器并发请求超限(如同时发起10个重试请求),是否通过队列控制并发;
- 检查重试请求的超时时间是否合理(如重试请求超时时间应短于首次请求,避免长期占用资源);
- 检查是否有"重试请求未取消"的情况(如组件卸载后仍在重试),导致内存泄漏。
-
服务器压力控制
- 检查是否对高频接口(如搜索、实时消息)的重试频率进行限制(如节流+低重试次数);
- 检查429(限流)错误是否会触发"延迟重试"(而非立即重试),是否尊重
Retry-After响应头; - 检查熔断器的
resetTimeout是否合理(如30秒,避免频繁切换状态)。
5.4 用户体验评审
5.4.1 核心检查点
-
重试过程透明度
- 检查关键操作(如订单提交)的重试是否有用户感知(如"网络波动,正在重试..."提示),避免用户以为操作失败而重复点击;
- 检查重试失败后是否有清晰的错误提示和手动重试入口,避免用户陷入"无反馈"困境;
- 示例:评审时模拟网络波动,验证是否有友好的重试中提示。
-
等待时间合理性
- 检查总重试耗时是否过长(如3次重试总耗时超过10秒),是否平衡"重试次数"与"用户等待耐心";
- 检查重试间隔是否随次数增长(避免用户感知"卡住"),同时不超过合理范围(如单次间隔≤4秒);
- 检查是否有"后台重试+前台反馈"机制(如列表加载失败在后台重试,成功后自动更新,无需用户操作)。
-
降级体验完整性
- 检查熔断或多次重试失败后,是否有降级内容(如缓存数据、默认内容),避免页面空白;
- 检查降级内容是否明确标记(如"展示历史数据"),避免用户误解为最新数据;
- 检查服务恢复后,是否能自动切换回正常请求(如熔断状态恢复后,无需用户刷新页面)。
六、对话小剧场:错误重试策略的团队争议与解决
场景设定
项目组在开发"商品详情页"时,测试反馈"网络波动时商品详情加载失败,用户需要手动刷新",但同时担心"重试会导致重复请求,加重服务器负担"。参会人员:
- 小美(前端开发):负责商品详情页开发,希望添加重试提升成功率;
- 小迪(前端开发):负责请求工具封装,担心重试策略设计不当引发问题;
- 大熊(后端开发):担心前端盲目重试导致服务器压力过大;
- 小燕(质量工程师):需验证重试策略的有效性与边界场景;
- 小稳(技术负责人):需平衡用户体验与系统稳定性。
讨论过程
1. 需求分歧:重试必要性与风险
小美 :用户反馈很强烈------在地铁里网络时好时坏,打开商品详情页经常白屏,必须手动刷新。我想给 getProductDetail 接口加重试,网络错误时自动重试2次,应该能解决大部分问题。
大熊:重试可以,但不能瞎重试!上次促销活动,前端对500错误重试,结果服务器越重试越卡,最后直接宕机了。你们得告诉我:哪些错误会重试?重试几次?间隔多久?不然后端不接。
小迪:我同意大熊的顾虑。现在的问题是:1. 商品详情是GET请求(幂等),重试安全;但2. 如果是商品评价(POST),重试可能重复提交;3. 重试间隔如果太短,比如100ms,并发量会翻倍。
小燕:我补充测试数据:上周模拟弱网环境,商品详情页加载失败率35%,其中80%是临时网络错误(重试后能成功)。但如果盲目重试3次,服务器请求量会增加2.5倍,需要评估后端承受能力。
2. 方案探讨:明确重试规则
小稳 :核心是"只对安全的错误,用合理的次数和间隔重试",分三步设计:
第一步,明确可重试错误:只对"网络错误""503服务不可用""504网关超时"重试,400/404/401这些客户端错误绝对不重试;商品详情是GET请求(幂等),允许重试;POST请求(如评价)默认不重试,除非后端实现幂等。
大熊 :500错误不能一概而论------如果是"数据库连接超时"这种临时错误,可以重试;但如果是"SQL语法错误"这种永久错误,重试没用。后端可以在500响应头加 X-Retryable: true,前端只对带这个头的500重试。
小迪 :那我在 isRetryable 函数里加判断:error.response?.headers['x-retryable'] === 'true'。
第二步,控制重试次数和间隔:商品详情这种非关键接口,最多重试2次,间隔用指数退避+抖动(1s±0.2s → 2s±0.4s),避免集中请求。
小美:2次重试够吗?弱网环境可能需要更多次。
小燕:测试显示2次重试能覆盖70%的临时错误,3次提升到75%,但请求量增加50%,性价比不高,2次足够。
第三步,加熔断保护:如果商品详情接口连续5次失败,且错误率超过50%,就熔断30秒,期间用本地缓存的商品数据,避免服务器雪崩。
大熊:这个好!后端出问题时,前端能自动"刹车",我们有缓冲时间恢复服务。
3. 落地细节:前后端配合
小稳:需要前后端配合的点:
- 幂等性确认 :所有需要重试的POST接口(如购物车添加),后端必须通过"用户ID+商品ID"或"幂等ID"实现去重,前端在请求头传递
X-Idempotency-Key; - 错误头约定 :后端对可重试的500错误返回
X-Retryable: true,对429错误返回Retry-After: 5(5秒后重试),前端尊重这些头信息; - 降级数据准备:前端缓存最近查看的10个商品详情,熔断时展示缓存,后端提供"热门商品缓存接口"作为备选。
小美:我来实现商品详情页的重试逻辑,加个"正在重试..."的提示,失败后显示缓存数据并提供"刷新最新"按钮。
小迪 :我更新请求工具,支持 X-Retryable 头判断和429延迟重试,集成熔断器。
大熊:后端这周实现幂等性和错误头,下周联调。
小燕:测试用例包含:弱网重试成功、400错误不重试、500带Retryable头重试、熔断后展示缓存、重复提交不创建多条数据。
小剧场总结
错误重试策略不是前端单方面的决策,需要:前端明确"何时重试、如何重试",后端提供"幂等支持、错误标记",测试验证"有效性与安全性",最终实现"提升用户体验"与"保护系统稳定"的平衡。
七、本章小结
错误重试是提升接口成功率的有效手段,但"盲目重试"不如"不重试"。本章基于Vue3 + TypeScript生态,总结出错误重试策略的落地关键:
-
重试前提:明确可重试错误
并非所有错误都适合重试,需严格过滤:
- 网络错误(
ERR_NETWORK等)、服务器临时错误(503、504)可重试; - 客户端错误(400、401等)、永久业务错误(库存不足)不可重试;
- 500错误需后端通过
X-Retryable头标记是否可重试,避免无效操作。
- 网络错误(
-
重试核心:控制次数与间隔
有效的重试需"次数合理、间隔分散":
- 次数:普通接口2-3次,关键接口1-2次,避免过度请求;
- 间隔:采用"指数退避+随机抖动"(如1s→2s→4s,每次±20%),分散请求压力,避免"重试风暴";
- 特殊处理:429(限流)错误需尊重
Retry-After头,延迟指定时间后重试。
-
重试安全:幂等性是底线
重试不能引发业务问题,需确保:
- GET请求天然幂等,可安全重试;
- POST请求需后端通过"幂等ID"实现去重,前端传递唯一ID并复用;
- 非幂等接口(如无去重机制的创建操作)禁止重试,或提示用户确认后手动重试。
-
系统保护:熔断机制不可少
当服务持续错误时,重试会加剧问题,需通过熔断器实现:
- 状态转换:
CLOSED(正常重试)→OPEN(暂停重试)→HALF_OPEN(试探恢复); - 触发条件:错误率超过阈值(如50%)且失败次数达标(如5次);
- 降级策略:熔断时返回缓存数据或友好提示,确保用户体验不中断。
- 状态转换:
-
用户体验:平衡透明与耐心
重试过程需让用户感知但不焦虑:
- 关键操作(如下单)显示"正在重试..."提示,避免用户重复操作;
- 总重试耗时控制在10秒内,单次间隔不超过4秒;
- 重试失败后提供清晰的错误信息和手动重试入口,配合降级内容(如缓存数据)。
错误重试的终极目标不是"100%成功率",而是"在系统稳定与用户体验之间找到最优解"------既通过智能重试解决大部分临时错误,又通过严格的安全与熔断机制保护系统,让用户感受到"可靠"而非"卡顿"。