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:
wangjia
2026-06-16 14:42:23 +08:00
parent 3f3aa41121
commit 018180de8c
8 changed files with 269 additions and 150 deletions
+16 -6
View File
@@ -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);
}
}