成为全栈·Flutter App 篇·移动端错误与限流:429、重试和写请求边界

成为全栈·Flutter App 篇·移动端错误与限流:429、重试和写请求边界

上一篇做完令牌轮换,我以为"网络层最难的部分"已经过去了。结果联调第二天就撞上另一个。

场景:用户狂点点赞按钮。服务端按 IP 限流,30 秒内第 5 次请求返回 429。我的第一版处理是:看到 429 就重试。

然后用户投诉的是另一个方向------"点赞偶尔会变成取消点赞。"

原因不难查:POST /likes 被自动重试了两次,服务端第一次已经记了点赞,第二次又记了一次切换。限流重试和写请求重放撞车了。

这篇讲 api_client.dart 里那套看起来很啰嗦的条件判断,以及为什么"重试"这个动作在移动端远没有想象中安全。

429 不是错误,是一个需要等待的信号

先纠正一个我最初的误解。我以为 429 和 500 是一类东西------服务端出错了,客户端重试就行。

实际上 429 的语义是明确的:你太快了,但我没有出错。 它区别于 500 的地方在于,500 重试可能还是 500,429 等一会儿大概率就好了。

所以客户端拿到 429 时,第一件事应该是读 Retry-After 响应头,而不是立刻重试:

dart 复制代码
Future<Response<dynamic>> _retryAfter(
  Response<dynamic> response,
  String path,
  String method,
  Map<String, dynamic> query,
  bool anonymous,
  int start,
) async {
  final seconds = int.tryParse(response.headers.value('retry-after') ?? '');
  // 只对 GET 做一次有界等待;写请求绝不自动重放。
  if (response.statusCode == 429 &&
      method == 'GET' && seconds != null && seconds >= 0 && seconds <= 3) {
    await Future<void>.delayed(Duration(seconds: seconds));
    if (start != epoch) throw SessionChanged();
    response = await dio.request(path, queryParameters: query, options: ...);
    if (start != epoch) throw SessionChanged();
  }
  return response;
}

这段代码只有十行,但四个条件缺一不可。每一个都能单独造成我开头那个 bug。

method == 'GET' ------ 最重要的一条。 写请求绝不自动重放。开头那个"点赞变成取消点赞"就是漏了它的直接后果:点赞是"切换"语义而不是"设置"语义,重试两次等于反转两次。

值得展开说清楚为什么这类接口特别危险。本项目的点赞端点是切换式的:

复制代码
用户点赞   → POST → 变成已赞
用户再点   → POST → 取消赞

客户端视角这俩是同一个请求,没法区分。所以重试时客户端不可能知道"我刚才那次到底成功了没有",也就无法"补发"------只能重发,而重发就是反转。

反过来,"设置"语义的接口(比如 PUT /profile 改成昵称)是幂等的,重试安全。同样是写请求,安全性完全不同。所以判断标准不是"读还是写",而是:

接口语义 重试安全性
设置(PUT 幂等)、删除 安全,重试无副作用
切换(点赞、收藏、关注) 不安全,重试 = 反转
创建(POST 稿件) 不安全,重试 = 重复创建

我现在的做法比较保守:客户端不判断接口语义,一律不重放写请求。 代价是网络抖动时用户的手动重试可能撞上已创建的稿件,需要靠"创建后拿到 id、后续失败复用 id"来兜(M4-19 会讲)。这个代价我认,因为它比"用户莫名其妙多了一篇稿件"好处理。

seconds <= 3 ------ 那个看起来武断的上限。 这是全文我最想解释的一行。

服务端完全可能返回 Retry-After: 60。那我们该不该等 60 秒?不能。 因为用户可能已经把 App 切走了,一分钟后回来发现界面还转着圈------那时候他已经不关心那条请求了。

等 3 秒是权衡后的结果:足够覆盖"服务端只是短暂抖动"(多数 Retry-After 在 1--2 秒),又短到用户不会觉得卡死。超过 3 秒的情况,直接把错误抛给 UI,让用户自己决定要不要手动重试。

这个设计的核心判断是:自动重试必须发生在用户还有耐心等待的窗口内。 超出了,就该把决定权交还给人------一个"请求较多,请稍后重试"的提示 + 一个重试按钮,永远好过一个自己转了 60 秒的圈。

seconds >= 0 ------ 防负数和畸形值。 int.tryParse 会给 null,但如果服务端返回的是 Retry-After: -5 或某个怪东西,负数传给 Duration 会直接抛异常。这行是纯粹的输入校验。

int.tryParse 本身 ------ 不假设头一定存在也不假设格式正确。 服务端可能压根不发这个头(很多框架默认不发),这时候 null 让条件不成立,走正常错误路径,而不是崩在解析上。

为什么还要查两次 epoch

上面那段里有两个 if (start != epoch) throw SessionChanged();,一个在 await 前,一个在重试请求返回后。

M4-09 讲过 epoch 是什么:会话代次,退出登录会 ++。这里它的作用是------等待的这 3 秒里,用户可能已经退出了。

而且注意检查的位置。等待之前的检查是"我发起请求时还是这个会话吗",等待之后的检查是"3 秒过去了,还是这个会话吗"。缺了第二个,等待结束后的重试会用已经登出的会话发出请求。

3 秒不长,但它是一个真实的 await 挂起点,而 M4-09 那条纪律------每个 await 之后都要重新确认前置假设------在这里同样适用。

错误码到人话:一张映射表

另一个我一开始偷懒的地方:错误怎么显示给用户。

我最初是直接把服务端的 message 透传出去。后来发现两个问题:一是文案风格不统一(有的接口返回中文,有的返回英文或空);二是有些码服务端根本没给 message,前端就显示 null。

现在的做法是一张集中映射表,api_client.dart 末尾:

dart 复制代码
static const _messages = {
  1001: '用户名或密码不正确',
  1002: '登录已过期,请重新登录',
  1003: '会话已失效,请重新登录',
  1004: '请登录后继续',
  1005: '账号已停用',
  2001: '你没有操作此内容的权限',
  3001: '内容不存在或暂不可见',
  3002: '内容存在冲突,请检查后重试',
  3003: '稿件状态已变化,请刷新后重试',
  4001: '请检查填写的内容',
  5000: '服务暂时不可用,请稍后重试',
  5001: '请求较多,请稍后重试',
};

这份表就是 M1 定的错误码分段:1xxx 认证 / 2xxx 授权 / 3xxx 资源 / 4xxx 参数 / 5xxx 服务(5001=限流)。前端拿到的码不需要查文档就能归类。

它还有个不太明显的价值:文案是可以被评审的。 这些都是产品文案,写在代码里能被人读、被讨论、被改。而如果散落在各个请求的失败分支里,就永远没人看了。

而 message 的取值有优先级:

dart 复制代码
throw ApiFailure(
  fields.isNotEmpty
      ? fields.values.join('\n')
      : _messages[code] ?? '请求未能完成',
  code: code,
  status: response.statusCode ?? 0,
  retryAfter: int.tryParse(response.headers.value('retry-after') ?? ''),
  fields: fields,
);

字段校验错误优先于通用文案。 表单提交时,用户需要知道"哪个字段错了",而不是"请检查填写的内容"------后者是废话。那段 fields 的收集是:

dart 复制代码
final fields = <String, String>{};
if (body['data'] is Map) {
  for (final e in ((body['data'] as Map)['errors'] as List? ?? [])) {
    if (e is Map) fields['${e['field']}'] = '${e['message']}';
  }
}

注意顺序:即使有字段错误,code 也会一起传出去(这里传的是 4001)。因为 UI 还需要靠它判断"这是字段错误,应该把用户送回表单"还是"这是权限错误,应该提示他"。

这跟 M4-07 讲的 forbidden() 是同一件事:错误码给程序判断,message 给人看,两者不能混。

超时和网络不可用要分开说

Dio 的异常不能一律变成 "网络错误"。api_client.dart 里做了区分:

dart 复制代码
} on DioException catch (e) {
  if (start != epoch) throw SessionChanged();
  throw ApiFailure(switch (e.type) {
    DioExceptionType.connectionTimeout ||
    DioExceptionType.receiveTimeout ||
    DioExceptionType.sendTimeout => '连接超时,请重试',
    _ => '网络连接不可用,请检查网络后重试',
  });
}

三个超时(连不上、发不出去、收不到)归成"连接超时,请重试",其余归成"网络连接不可用,请检查网络"。

分开的理由是用户的下一步动作不同:超时意味着"再试一次可能就好",所以提示里带"请重试";网络不可用(用户关了 WiFi、飞行模式)意味着"重试也没用,先去开网络",所以提示里带"请检查网络"。

用户按提示做不同的事,体验差别很大。

重试这条路的代价:它很贵

最后说一个我在做这个功能时才意识到的现实。

Retry-After 重试需要 await Future.delayed,这意味着这个 Future 挂在那儿三秒 。如果用户此时退出页面,那个请求的结果谁要?没人。于是我引入的是 M4-12 讲过的那套------CacheSuperseded:

dart 复制代码
if (!mounted || ticket != serial) return;

而在 api_client.dart 这一层,它体现为 epoch 检查。每一层都要有"我还需要继续吗"的判断,因为没人能保证上层还活着。

这就是有界重试的另一个成本:它不是一个纯函数式的"重试一次",它引入了跨层的状态依赖 。这也是我为什么把上限设成 3 秒而不是 10 秒------等待越长,跨层失联的概率越高。

一条我一开始想省掉的事

上面所有的条件判断,加起来二十多行,套在一句话上:

30 秒内第 5 次请求返回 429,我的第一版处理是:看到 429 就重试。然后用户投诉"点赞偶尔会变成取消点赞"。

修这个 bug 的过程里,我最强烈的感受不是"我写错了",而是**"这么简单的错误,测试环境为什么测不出来"**。

答案很清楚:测试环境我不会去连点 30 次点赞。所以这个 bug 是被真实用户发现的,不是被测试发现的。

于是我给限流逻辑补了两条测试:

  1. 直接构造一个 Retry-After: 429 的响应(不真的限流),断言写方法不重试、GET 方法重试一次
  2. 构造 Retry-After: 60,断言不等待,直接抛错

第二条尤其重要------它测的不是功能,是我主动放弃的那个功能 。如果将来有人"优化"掉那个 <= 3,测试立刻会红。

给"我故意不做的事"写测试,是我在这个项目里学到的新做法。之前我只知道给代码写测试,而那些代码无论怎么变都应该是对的;但"为什么这里有个看起来多余的上限"这种决策,如果不锁住,半年后连我自己都会想不起来。

顺带说一句,测试能写出来是因为 _retryAfter 是个接受 Response 参数的纯函数式方法 ------它不依赖 dio 的真实网络,只依赖传进来的响应对象。这大概是我在写这个功能时最满意的一个设计决定,虽然当时是为了测试方便才这么拆的。

小结

限流和重试这一块,真正难的不是"怎么重试",而是判断什么时候不该重试。

三个判断标准:

  1. 方法语义决定能不能重试 ------ 切换式写接口重试就是反转,无论什么错误码都不重试。
  2. 等待时长必须在用户耐心窗口内 ------ 超过 3 秒就交给用户手动决定。
  3. 每次 await 之后都要重新确认会话和页面还活着 ------ 等待本身就是失联窗口。

再往下一层,是 M4-09 和 M4-12 都出现过的同一条纪律:跨层的异步操作,每一层都得自己判断是否还有效。 客户端没有"统一的取消机制",因为它天然是跨 Widget、跨缓存、跨会话的。

下一篇讲首页怎么用真实 API 组装------焦点、最新、热门三个区块各自的数据来源和缓存策略各不相同,这是下一篇的第一个难点。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
恋猫de小郭1 小时前
Kotlin Toolchain 0.13,JetBrains 推荐 KMP 都切换到到全新工具链
android·前端·flutter
m0_738185821 小时前
Flutter 鸿蒙化实战:rive 适配 OpenHarmony,Rive 动画
flutter·华为·harmonyos·鸿蒙
Geek_Vison13 小时前
保险APP已经用了Flutter,还能接入小程序吗?
flutter·小程序·uni-app·mpaas·小程序容器
FungLeo13 小时前
成为全栈·Flutter App 篇·go_router 与四 Tab App Shell:保留导航状态、深链和登录回跳
flutter·移动开发·成为全栈·go_router·路由设计·深链接
m0_7381858214 小时前
Flutter 鸿蒙化实战:screen_protector 适配 OpenHarmony,防截屏/防泄露
flutter·华为·harmonyos·鸿蒙
梦想的颜色19 小时前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
yuanlaile2 天前
2026年全新Flutter 跨平台开发全栈学习资料整理
flutter·getx·鸿蒙 next·flutter开发鸿蒙·flutter入门实战·flutter全栈
恋猫de小郭2 天前
Genkit Dart 1.0 发布,Flutter 原生的 AI Agent 终于完整了
android·前端·flutter
第10086个小白2 天前
flutter桌面端打包方式(Inno Setup Compiler)版本
前端·flutter