JavaScript 性能优化系列(六)接口调用优化 - 6.4 错误重试策略:智能重试机制,提高请求成功率
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拦截器实现全局重试逻辑。
// 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; // 内部使用:是否幂等(用于安全校验)
}
// 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、504isRetryable: (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));
};
// 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();
// 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);
};
<!-- 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 完整代码实现
扩展错误判断逻辑,支持业务错误码的可重试性校验。
// 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;}
}
// 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) {// 业务错误:抛出BusinessErrorthrow new BusinessError(data.message, data.code, response);}return response; // 业务成功:返回数据
};
// 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) // 失败响应:交给重试逻辑处理
);
// 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);
};
<!-- 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生成与重试机制,确保关键操作重试安全。
// 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;}
};
// 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;}
};
<!-- 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, // 假设选中的地址IDpaymentType: '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),动态调整重试策略。
// 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; // 熔断器打开至何时(时间戳)
}
// 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;}
}
// 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);}
);
// 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 [];}
};
<!-- 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错误也重试)
// 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状态码但业务错误的情况,根据错误码决定是否重试。
修复后的核心代码:
// 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 错误代码(订单提交无幂等性重试)
// 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 // 错误标记:实际后端未实现幂等});
};
<!-- 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,禁止重试。
修复后的核心代码:
// 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短间隔重试)
// 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次以内),降低总请求量。
修复后的核心代码:
// 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→4sconst jitter = Math.random() * 500; // ±250ms随机抖动return base - jitter;}
});
4.4 反例4:无熔断机制,服务故障时持续重试
4.4.1 错误代码(服务宕机仍持续重试)
// 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 } });
};
<!-- 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次,避免过度请求。
修复后的核心代码:
// src/utils/correctWithCircuitBreaker.ts - 带熔断的搜索
import { circuitBreakerAxios } from '@/utils/axiosWithCircuitBreaker';
import { throttle } from 'lodash'; // 引入节流函数// 修复1:添加节流(300ms一次)
export const throttledSearch = throttle(async (keyword: string) => {try {// 修复2:使用带熔断的Axiosconst 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%成功率”,而是“在系统稳定与用户体验之间找到最优解”——既通过智能重试解决大部分临时错误,又通过严格的安全与熔断机制保护系统,让用户感受到“可靠”而非“卡顿”。
