Flutter API 连接超时重试时如何保证幂等性
结论
Flutter 客户端无法独自保证购买操作的幂等性。服务端需要为每次业务操作分配或接收唯一的 idempotencyKey,并通过数据库唯一约束保存处理结果。客户端超时后,应继续使用同一个 key 查询状态或重试,不能生成新 key。
连接超时只说明客户端没有收到响应,不能证明服务端执行失败。因此,捕获 DioException 后不应无限递归调用购买接口。
为什么超时后不能直接重试
一次请求可能经历以下过程:
- 服务端收到购买请求。
- 服务端成功发放产品。
- 服务端准备返回成功响应。
- 响应在网络传输中断,或者客户端等待超时。
客户端此时看到的是超时,但购买可能已经完成。如果服务端把重试识别为新订单,用户就可能收到两份产品。
因此,超时后的状态应当是”结果未知”,而不是”失败”。
推荐的接口设计
最好把”收款”和”购买产品”整合成一个由业务服务端编排的订单流程,不要让 Flutter 依次调用两个互不关联的 API。
典型流程如下:
- Flutter 创建购买订单并获得唯一的
orderId。 - 服务端执行支付或确认支付结果。
- 服务端调用产品供应方发放产品。
- 服务端记录订单状态和供应方交易号。
- Flutter 根据
orderId查询最终状态。
如果暂时无法合并两个接口,至少应让第二个购买接口支持幂等键:
POST /purchases
Idempotency-Key: purchase_123456
请求体也可以携带同一个业务标识:
{
"orderId": "purchase_123456",
"productId": "product_001"
}
服务端需要做到以下几点:
- 在数据库中为
orderId或Idempotency-Key设置唯一约束。 - 由第一次请求创建并处理订单。
- 相同 key 的后续请求不能再次发放产品。
- 订单已经成功时,返回第一次请求的成功结果。
- 订单仍在处理时,返回
processing。 - 订单明确失败且允许重新处理时,由服务端决定是否继续执行。
- 多个相同请求并发到达时,也只能执行一次。
只在内存中记录 key 并不可靠。服务重启、多实例部署和并发请求都可能绕过内存状态,最终仍要依靠数据库唯一约束和事务处理来保证幂等性。
Flutter 中的处理方式
假设服务端支持以下接口:
POST /purchases
GET /purchases/{orderId}
用户点击购买时,客户端生成一次 orderId,或者从服务端获取该值。此后直到确认最终结果,都要复用同一个 orderId。
import 'dart:async';
import 'dart:math';
import 'package:dio/dio.dart';
class PurchaseClient {
PurchaseClient(this.dio);
final Dio dio;
final Random _random = Random();
Future<Map<String, dynamic>> purchase({
required String orderId,
required String productId,
}) async {
const maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
try {
final response = await dio.post<Map<String, dynamic>>(
'/purchases',
data: {
'orderId': orderId,
'productId': productId,
},
options: Options(
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Idempotency-Key': orderId,
},
),
);
return response.data ?? <String, dynamic>{};
} on DioException catch (e) {
if (!_isRetryable(e)) {
rethrow;
}
// 超时后的执行结果未知,优先查询订单状态。
final existing = await _queryPurchase(orderId);
final status = existing?['status'];
if (status == 'succeeded') {
return existing!;
}
if (status == 'failed') {
throw StateError(
existing?['message']?.toString() ?? 'Purchase failed',
);
}
if (attempt == maxAttempts) {
throw PurchaseResultUnknownException(orderId);
}
await Future<void>.delayed(_retryDelay(attempt));
}
}
throw PurchaseResultUnknownException(orderId);
}
Future<Map<String, dynamic>?> _queryPurchase(String orderId) async {
try {
final response = await dio.get<Map<String, dynamic>>(
'/purchases/$orderId',
);
return response.data;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
return null;
}
// 查询接口本身也可能暂时无法连接,此时仍不能断定购买失败。
if (_isRetryable(e)) {
return null;
}
rethrow;
}
}
bool _isRetryable(DioException e) {
return e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError;
}
Duration _retryDelay(int attempt) {
final exponentialMs = 500 * (1 << (attempt - 1));
final jitterMs = _random.nextInt(250);
return Duration(milliseconds: exponentialMs + jitterMs);
}
}
class PurchaseResultUnknownException implements Exception {
PurchaseResultUnknownException(this.orderId);
final String orderId;
@override
String toString() {
return 'Purchase result is unknown for order $orderId';
}
}
重点不在于重试多少次,而在于每次调用都使用相同的 orderId 和 Idempotency-Key。即使第一次请求已经成功,服务端也应返回原订单的结果,不能再次执行购买。
订单标识必须持久化
如果应用在请求期间关闭,只把 orderId 保存在内存中仍然不够。客户端应在发起请求前保存待处理订单,例如记录:
orderId
productId
createdAt
localStatus
应用重新启动后,先根据 orderId 查询服务端状态,再决定展示成功、失败,还是继续等待。只有订单进入明确的终态,才能清理本地记录。
每次重试时都不能重新生成 orderId,否则服务端会把重试当成一笔新的购买。
服务端幂等处理示意
核心逻辑可以写成:
begin transaction
insert purchase_order(order_id, status)
values (:orderId, 'processing')
on conflict do nothing
load purchase_order where order_id = :orderId for update
if status = 'succeeded':
return saved result
if status = 'processing' and another worker owns it:
return processing
call product provider using a stable provider transaction id
save provider transaction id
save product delivery result
set status = 'succeeded'
commit
return saved result
实际实现还要考虑一种情况:服务端调用供应方成功后、写入成功状态前,进程崩溃了。如果供应方支持幂等键,服务端应向它传递稳定的交易标识。如果供应方不支持幂等键,则必须能够按交易号查询结果,或者提供人工对账机制。
如果第三方购买接口既不支持幂等,也无法查询状态,系统就无法从技术上严格保证请求不会重复,只能通过供应方交易号、业务去重和补偿流程降低风险。
支付与发货不要依赖客户端串联
“支付成功但发货失败”是分布式业务一致性问题,通常需要使用订单状态机或 Saga/补偿流程,例如:
created → paid → delivering → completed
↓
delivery_failed
对于 delivery_failed 订单,服务端可以继续尝试发货,也可以在达到重试上限后退款或转交人工处理。如何选择,取决于支付渠道和产品供应方是否支持退款、状态查询及幂等调用。
Flutter 只负责提交购买意图、展示订单状态并触发安全的状态查询,不应负责协调跨系统事务。
原重试代码的问题
原函数有以下风险:
- 捕获所有
DioException后立即重试,其中包括400、401、403等不可重试的错误。 - 递归调用没有次数限制,长期断网时可能无限执行。
- 没有退避间隔,可能在短时间内向服务端发送大量请求。
- 缺少稳定的业务订单号,服务端无法识别重复请求。
- 超时后没有查询订单状态。
- 用户重复点击按钮时,可能产生并发购买。
界面层还应暂时禁用购买按钮,避免同一订单被并发提交。不过,按钮防重复只能改善交互,无法替代服务端的幂等设计。
最终原则是:同一次业务操作始终使用同一个幂等键;超时表示结果未知;先查询状态,再进行有限次数的重试;真正的去重由服务端数据库和业务流程保证。
备注:内容仅供参考。
