Flutter http.post 请求持续 pending 并返回 HTTP 499
结论
这更像是 Flutter Web 的浏览器请求被取消。首先应排查 CORS 预检是否失败,而不是把问题归因于 FutureBuilder 或默认 timeout。
请求包含 Authorization,并使用了 Content-Type: application/json,浏览器通常会先发送 OPTIONS 预检请求。Python 不受浏览器同源策略限制,因此相同请求在 Python 中成功,并不代表 Flutter Web 也能直接调用。
HTTP 499 也不是 Flutter 收到的响应码,而是 nginx 写入服务端日志的状态。它表示服务端还没有处理完请求,客户端连接就已经断开。CORS、页面刷新或热重启、浏览器导航、主动关闭 HTTP client,以及显式配置的超时,都可能导致这种情况。单纯增加 nginx timeout 通常解决不了问题。
另外,不要在 Flutter Web 中保存支付 API 的 Basic Auth 凭据。即使 CORS 已经解决,访问页面的人仍能从浏览器代码或 Network 面板中取得凭据。请求应采用下面的结构:
Flutter Web
↓ 调用自己的后端
Your Backend
↓ 携带 Basic Auth
Payment API
为什么 snapshot.error 看起来是空的
只要 snapshot.hasError 为 true,原则上 snapshot.error 就不会是 null。所谓的“空错误”,通常有以下几种原因:
- 浏览器只向 Dart 暴露了信息很少的网络异常,例如早期版本中的
XMLHttpRequest error。 - 异常对象的
toString()没有返回有效内容。 catch只输出了异常,没有记录异常类型和堆栈。- 捕获异常后没有重新抛出,函数继续执行,随后产生了另一个错误。
- 浏览器因 CORS 拦截响应,JavaScript 无权读取真实状态码和响应正文。
诊断时,不要只打印 e.toString()。还应记录异常类型、堆栈、请求地址和耗时:
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
Future<PaymentPage> getPaymentPage(
double total,
String description,
) async {
final uri = Uri.parse(
'https://test.saferpay.com/api/Payment/v1/PaymentPage/Initialize',
);
final credentials = base64Encode(utf8.encode(authHeader));
final payload = {
'RequestHeader': {
'SpecVersion': '1.24',
'CustomerId': customerId,
'RequestId': createUniqueRequestId(),
'RetryIndicator': 0,
},
'TerminalId': terminalId,
'PaymentMethods': ['TWINT'],
'Payment': {
'Amount': {
'Value': total.toStringAsFixed(0),
'CurrencyCode': 'CHF',
},
'OrderId': '1234',
'Description': description,
},
'ReturnUrls': {
'Success': 'https://amazon.com',
'Fail': 'https://youtube.com',
},
};
final stopwatch = Stopwatch()..start();
try {
final response = await http
.post(
uri,
headers: {
'Authorization': 'Basic $credentials',
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json',
},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 30));
debugPrint(
'POST $uri completed in ${stopwatch.elapsedMilliseconds} ms, '
'status=${response.statusCode}',
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
statusCode: response.statusCode,
body: utf8.decode(response.bodyBytes),
);
}
final responseText = utf8.decode(response.bodyBytes);
return PaymentPageFromJson(responseText);
} on TimeoutException catch (error, stackTrace) {
debugPrint('Request timed out after ${stopwatch.elapsed}: $error');
debugPrintStack(stackTrace: stackTrace);
rethrow;
} on http.ClientException catch (error, stackTrace) {
debugPrint('HTTP client error: ${error.runtimeType}: $error');
debugPrintStack(stackTrace: stackTrace);
rethrow;
} catch (error, stackTrace) {
debugPrint('Unexpected error type: ${error.runtimeType}');
debugPrint('Unexpected error: ${Error.safeToString(error)}');
debugPrintStack(stackTrace: stackTrace);
rethrow;
} finally {
stopwatch.stop();
}
}
class HttpException implements Exception {
const HttpException({
required this.statusCode,
required this.body,
});
final int statusCode;
final String body;
@override
String toString() => 'HTTP $statusCode: $body';
}
这里的 createUniqueRequestId() 只是示意。实际使用时,应按照支付接口的要求生成唯一请求 ID,不能始终返回同一个值。
需要特别注意 rethrow。如果捕获异常后继续执行,而 response 仍是 null,接下来访问 response.statusCode 会产生新的异常,并掩盖最初的网络问题。
FutureBuilder 中也应输出堆栈,并处理请求完成但没有数据的情况:
FutureBuilder<PaymentPage>(
future: paymentPage,
builder: (context, snapshot) {
if (snapshot.hasError) {
debugPrint('Future error type: ${snapshot.error.runtimeType}');
debugPrint(
'Future error: ${Error.safeToString(snapshot.error)}',
);
debugPrintStack(stackTrace: snapshot.stackTrace);
return Center(
child: SelectableText(
'${snapshot.error.runtimeType}: '
'${Error.safeToString(snapshot.error)}',
),
);
}
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData) {
return const Center(
child: Text('请求已完成,但服务器没有返回支付页面数据'),
);
}
final url = snapshot.data!.redirectUrl;
// 使用 url 构建支付页面。
return Text(url);
},
)
同时,建议为 Future 指定明确类型,并先调用 super.initState():
late final Future<PaymentPage> paymentPage;
@override
void initState() {
super.initState();
paymentPage = APIManagerSaferpay().getPaymentPage(
widget.price,
widget.description,
);
}
重点检查 CORS 预检
打开浏览器开发者工具的 Network 面板后,不要只看处于 pending 状态的记录,还要检查以下内容:
- 是否有发往同一地址的
OPTIONS请求。 OPTIONS请求是否失败、被取消或一直处于 pending 状态。- Console 中是否出现
CORS policy、Failed to fetch、XMLHttpRequest error等信息。 - 服务端所说的“收到请求”究竟指
OPTIONS还是POST。 - 响应中是否包含匹配的 CORS header。
服务端至少要正确处理预检,并根据实际来源返回类似下面的响应头:
Access-Control-Allow-Origin: https://your-frontend.example
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
这些 header 必须由支付服务或中间后端返回。把 Access-Control-Allow-Origin 放进 Flutter 的请求 header 没有作用,因为它是响应 header。
如果支付 API 不支持浏览器跨域调用,前端无法自行绕过限制。应让自己的后端调用支付 API,再将支付 URL 返回给 Flutter:
final response = await http.post(
Uri.parse('https://api.your-domain.example/payments/initialize'),
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'amount': total.toStringAsFixed(0),
'description': description,
}),
);
Basic Auth 的用户名和密码只能保存在后端,不能打包进 Flutter Web。
timeout 应该怎样配置
在这里展示的代码中,package:http 的顶层 http.post() 默认没有 30 秒业务超时。除非其他代码、浏览器环境、代理或 SDK 另行设置了超时,否则不能只根据 499 判断发生了 Dart timeout。
可以使用 Future.timeout 限制整个等待过程:
final response = await http
.post(
uri,
headers: headers,
body: body,
)
.timeout(const Duration(seconds: 60));
这样,调用方会在 60 秒后收到 TimeoutException。不过,Future.timeout 主要限制 Dart 等待结果的时间,不保证所有平台和 HTTP 实现都会立即取消底层网络操作。它适合用于诊断和控制界面等待时间,但不能用来修复 CORS 或服务端延迟。
在 Android、iOS 或桌面端,如果需要分别控制连接建立超时和整个请求超时,可以使用 IOClient:
import 'dart:async';
import 'dart:io';
import 'package:http/io_client.dart';
final dartClient = HttpClient()
..connectionTimeout = const Duration(seconds: 15);
final client = IOClient(dartClient);
try {
final response = await client
.post(
uri,
headers: headers,
body: body,
)
.timeout(const Duration(seconds: 60));
// 处理 response
} finally {
client.close();
}
connectionTimeout 只限制建立连接所需的时间,并不等于服务器响应超时。Flutter Web 使用浏览器网络栈,不能通过 dart:io 配置这些参数。
如何验证是否真的是 timeout
不要一开始就盲目调大超时。可以同时记录以下时间点:
- Flutter 发起请求的时间。
- Flutter 抛出异常或页面被销毁的时间。
- nginx 开始处理请求的时间。
- nginx 记录 499 的时间。
- 上游支付服务完成处理的时间。
只有当客户端总是在固定的 30 秒或 60 秒断开,并且代码、反向代理或网关中确实有相同的超时配置时,才能较有把握地判断是 timeout。如果断开时间不固定,而且与页面刷新、热重启、路由跳转或 CORS 错误同时发生,则应优先调查客户端取消和浏览器策略。
增加 nginx 的 proxy_read_timeout 只能避免 nginx 在等待上游时自行超时,无法让已经关闭页面或取消请求的浏览器继续等待。499 表示 nginx 发现下游客户端先断开了连接,因此需要查清究竟是谁关闭了连接。
还需要修正的请求差异
Dart 与 Python 示例并不完全一致:
- Dart 使用
Twint,Python 使用TWINT。 - Dart 的金额来自
"$total",可能得到"200.0",而 Python 发送"200"。 - Dart 使用固定的
RequestId,Python 使用的请求 ID 看起来是唯一的。 description参数在原 Dart 函数中没有真正写入请求正文。
如果这些字段不符合接口约定,通常会收到可以读取的 HTTP 4xx 响应,而不是 499。不过,仍应按照支付 API 的字段规范逐项对齐。
综合判断,较可靠的处理顺序是先通过浏览器 Console 和 OPTIONS 请求确认 CORS,再把支付初始化请求迁移到后端。同时记录异常类型、堆栈、HTTP 状态和响应正文。timeout 可以作为诊断边界来配置,但不应直接视为 499 的根因。
备注:内容仅供参考。
