feat(client): 网络请求重试 + 离线提示可感知重试按钮
修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增 1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。 connectTimeout 5s→8s。 修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线; 新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton, 重试中转圈、结果 SnackBar 反馈。 121 个测试通过,analyze 无 error。todo #52。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
final _publicDio = _buildPublicDio();
|
||||
|
||||
Dio _buildPublicDio() {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
dio.interceptors.add(RetryInterceptor(dio));
|
||||
return dio;
|
||||
}
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
// 只监听登录/登出,不监听 token 内容变化。
|
||||
@@ -52,13 +59,16 @@ class ApiClient {
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
));
|
||||
|
||||
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
|
||||
_dio.interceptors.add(RetryInterceptor(_dio));
|
||||
|
||||
// 网络错误 + 401 拦截器
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 网络层错误自动重试拦截器。
|
||||
///
|
||||
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
|
||||
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
|
||||
///
|
||||
/// 安全策略(避免重复入库/出库等副作用):
|
||||
/// - GET:幂等,所有网络错误都重试。
|
||||
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError,
|
||||
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
|
||||
/// 不重试,避免重复提交。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
final List<Duration> delays;
|
||||
|
||||
RetryInterceptor(
|
||||
this.dio, {
|
||||
this.delays = const [
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 4),
|
||||
],
|
||||
});
|
||||
|
||||
static const _attemptKey = 'retry_attempt';
|
||||
|
||||
bool _retriable(DioException e) {
|
||||
final method = (e.requestOptions.method).toUpperCase();
|
||||
final isIdempotent = method == 'GET' || method == 'HEAD';
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
|
||||
return true;
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
// 服务器可能已处理 → 仅幂等方法重试
|
||||
return isIdempotent;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
|
||||
|
||||
if (_retriable(err) && attempt < delays.length) {
|
||||
await Future.delayed(delays[attempt]);
|
||||
err.requestOptions.extra[_attemptKey] = attempt + 1;
|
||||
try {
|
||||
final resp = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (e) {
|
||||
return handler.next(e);
|
||||
} catch (_) {
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user