成为全栈·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 是被真实用户发现的,不是被测试发现的。
于是我给限流逻辑补了两条测试:
- 直接构造一个
Retry-After: 429的响应(不真的限流),断言写方法不重试、GET 方法重试一次 - 构造
Retry-After: 60,断言不等待,直接抛错
第二条尤其重要------它测的不是功能,是我主动放弃的那个功能 。如果将来有人"优化"掉那个 <= 3,测试立刻会红。
给"我故意不做的事"写测试,是我在这个项目里学到的新做法。之前我只知道给代码写测试,而那些代码无论怎么变都应该是对的;但"为什么这里有个看起来多余的上限"这种决策,如果不锁住,半年后连我自己都会想不起来。
顺带说一句,测试能写出来是因为 _retryAfter 是个接受 Response 参数的纯函数式方法 ------它不依赖 dio 的真实网络,只依赖传进来的响应对象。这大概是我在写这个功能时最满意的一个设计决定,虽然当时是为了测试方便才这么拆的。
小结
限流和重试这一块,真正难的不是"怎么重试",而是判断什么时候不该重试。
三个判断标准:
- 方法语义决定能不能重试 ------ 切换式写接口重试就是反转,无论什么错误码都不重试。
- 等待时长必须在用户耐心窗口内 ------ 超过 3 秒就交给用户手动决定。
- 每次
await之后都要重新确认会话和页面还活着 ------ 等待本身就是失联窗口。
再往下一层,是 M4-09 和 M4-12 都出现过的同一条纪律:跨层的异步操作,每一层都得自己判断是否还有效。 客户端没有"统一的取消机制",因为它天然是跨 Widget、跨缓存、跨会话的。
下一篇讲首页怎么用真实 API 组装------焦点、最新、热门三个区块各自的数据来源和缓存策略各不相同,这是下一篇的第一个难点。
延伸阅读
- Dio + Repository:统一响应信封与模型适配
- React 请求层封装:统一信封、业务错误与并发 401
- 统一响应结构:HTTP 状态码与业务码如何分工
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
