PAYATHON 2026

Flutter API 连接超时重试时如何保证幂等性

支付老李

结论

Flutter 客户端无法独自保证购买操作的幂等性。服务端需要为每次业务操作分配或接收唯一的 idempotencyKey,并通过数据库唯一约束保存处理结果。客户端超时后,应继续使用同一个 key 查询状态或重试,不能生成新 key。

连接超时只说明客户端没有收到响应,不能证明服务端执行失败。因此,捕获 DioException 后不应无限递归调用购买接口。

为什么超时后不能直接重试

一次请求可能经历以下过程:

  1. 服务端收到购买请求。
  2. 服务端成功发放产品。
  3. 服务端准备返回成功响应。
  4. 响应在网络传输中断,或者客户端等待超时。

客户端此时看到的是超时,但购买可能已经完成。如果服务端把重试识别为新订单,用户就可能收到两份产品。

因此,超时后的状态应当是”结果未知”,而不是”失败”。

推荐的接口设计

最好把”收款”和”购买产品”整合成一个由业务服务端编排的订单流程,不要让 Flutter 依次调用两个互不关联的 API。

典型流程如下:

  1. Flutter 创建购买订单并获得唯一的 orderId。
  2. 服务端执行支付或确认支付结果。
  3. 服务端调用产品供应方发放产品。
  4. 服务端记录订单状态和供应方交易号。
  5. 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 等不可重试的错误。
  • 递归调用没有次数限制,长期断网时可能无限执行。
  • 没有退避间隔,可能在短时间内向服务端发送大量请求。
  • 缺少稳定的业务订单号,服务端无法识别重复请求。
  • 超时后没有查询订单状态。
  • 用户重复点击按钮时,可能产生并发购买。

界面层还应暂时禁用购买按钮,避免同一订单被并发提交。不过,按钮防重复只能改善交互,无法替代服务端的幂等设计。

最终原则是:同一次业务操作始终使用同一个幂等键;超时表示结果未知;先查询状态,再进行有限次数的重试;真正的去重由服务端数据库和业务流程保证。

备注:内容仅供参考。