让阿里云 OSS 真的当 S3 用——「S3 兼容」不是契约
Shellby 复盘:同步支持用户自带 S3 存储,一个用户填了阿里云 OSS 就同步失败。顺着 403 空响应体一路挖到寻址、条件写、连接被关四个坑,每一个都是「S3 兼容 ≠ S3」的一个侧面。
Shellby 的同步让用户自带存储:填一个 S3 兼容的桶,多端配置就经它同步。远端只放一个加密后的密文对象加一个版本标签(ETag),用条件写做乐观并发。在 AWS S3 和 Cloudflare R2 上一切正常。然后一个用户填了阿里云 OSS,同步直接失败。
顺着这个工单挖下去,是一串「S3 兼容」的坑。这篇把它们串起来——每一个都在说同一件事:「S3 兼容」只是协议长得像,寻址约束、可选特性、错误载体、连接生命周期,各家都可能不一样。
坑一:403,而且响应体是空的
第一个症状最劝退:连 OSS 报 403,响应体空的,看不出是签名错、权限错还是密钥错。
根因是寻址风格。S3 的对象地址有两种写法:path-style(endpoint/bucket/key)和 virtual-hosted(bucket.endpoint/key,桶名进域名)。AWS 和 R2 两种都认,而阿里云 OSS 只认 virtual-hosted——path-style 请求在到达鉴权之前就被它的路由层 403 掉了,所以连错误体都没有。
修法是自动判寻址风格:端点是 DNS 主机名(非 IP、非 localhost)且桶名 DNS 合规,就走 virtual-hosted;IP / localhost 端点或非法桶名(比如带下划线)走 path-style,保住 MinIO 和本地测试。
bool _computeVirtualHosted() {
final host = endpoint.split(':').first;
final isIp = RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host) || host == 'localhost';
final dnsSafeBucket = RegExp(r'^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$').hasMatch(bucket);
return !isIp && dnsSafeBucket;
}
但这里有个连带的暗坑,漏了就换来另一个 403:寻址风格改了,SigV4 签名的规范请求也得跟着改。virtual-hosted 时签名串里的 path 只有 /key,path-style 时是 /bucket/key,host 头也从 uri 自动取 bucket.endpoint。签名串和真实请求不一致,OSS 直接回 SignatureDoesNotMatch。
坑二:400 NotImplemented——它不支持条件写
寻址修好,PUT 又 400 了,这次响应体里有字:NotImplemented。
我们的同步引擎用条件写做乐观并发:首次上传用 If-None-Match: *(要求对象还不存在),更新用 If-Match: "etag"(compare-and-swap,防覆盖别人的写)。OSS 的 S3 兼容 API 不支持这些条件头,带上就 400。
不能因为一个后端不支持就砍掉所有后端的乐观并发。修法是运行时特性探测 + 降级:provider 持一个 _conditionalPut 标志,首个 PUT 带条件头;如果回 400/501 且响应体含 NotImplemented,就把标志置 false、去掉条件头重试一次,之后所有 PUT 走普通写。R2/AWS 上标志一直是 true,乐观并发照旧。
var resp = await _send('PUT', key, body, headers(conditional: _conditionalPut));
if (_conditionalPut &&
(resp.statusCode == 400 || resp.statusCode == 501) &&
resp.body.contains('NotImplemented')) {
_conditionalPut = false; // 探测到不支持,永久降级
resp = await _send('PUT', key, body, headers(conditional: false));
}
降级敢做的前提是上层有兜底语义:CAS 没了,并发冲突改由引擎的记录级 LWW(last-write-wins)+ tombstone 确定性合并来收,而且「先拉取-合并-再落地」的顺序保证了不丢对端更新。如果上层没有合并兜底,CAS 是不能随便降级的——那样降级就等于允许 lost update。
坑三:错误码被状态码吞了
前两个坑能快速定位,靠的是中途补的一件事:把响应体里的真错误透出来。
早期 provider 遇到非 2xx 只显状态码——s3 put 403、s3 get 500——签名问题、权限问题、密钥问题全糊成一个「403」。而 S3/OSS 的真因其实在响应体 XML 的 <Code> / <Message> 里。补一个解析:
static String _errBody(http.Response resp) {
String? tag(String t) => RegExp('<$t>(.*?)</$t>', dotAll: true).firstMatch(resp.body)?.group(1)?.trim();
final code = tag('Code'), msg = tag('Message');
if (code == null && msg == null) return '${resp.statusCode}'; // 回落纯状态码
return '${resp.statusCode} ${code ?? ''}${msg != null ? ': $msg' : ''}';
}
有意思的是,坑一那个「403 空响应体」正好落进 if (code == null && msg == null) 的回落分支——而「响应体是空的」本身就是一条诊断信号:多半是路由层在鉴权前就拒了,也就是寻址不对。不透出原始错误,这个坑会被一直误判成密钥或签名问题。
坑四:连接被关,-1005
降级之后,又冒出一类飘忽的失败:Flutter 抛 Connection closed before full header was received,Apple 抛 -1005 网络连接已断开。而且专挑上一次 400 错误响应之后那一发请求。
根因是连接生命周期:OSS 在一次响应(尤其错误响应)后会主动关掉 keep-alive 连接,但客户端的连接池不知道,下一发请求复用到这条死连接,就瞬态失败了。
修法是加瞬态重试,但重试的边界要划死——只重网络级异常,不重 HTTP 错误响应:
for attempt in 0..<maxAttempts {
if attempt > 0 { try? await Task.sleep(nanoseconds: 150_000_000 * UInt64(attempt)) }
do {
let (data, resp) = try await session.data(for: request)
return (data, http)
} catch let e as SyncProviderError { throw e // 非网络 → 不重试
} catch let e as URLError where e.code != .cancelled { lastError = e } // 瞬态 → 重试
}
几个刻意的选择:4xx/5xx 是正常返回、不是异常,天然不进重试分支——500 也不重试,避免把服务端的确定性错误当瞬态刷;显式排除用户取消(.cancelled);最多 3 次、退避 150ms × attempt 递增;两端行为对齐(这个坑先在 Flutter 淌过,再原样搬到 Apple,顺手把重试抽成 S3/WebDAV 共用的 HTTPTransport)。
敢重试的底气是幂等:PUT 是整对象覆盖写同一个 key(不是 append),每次重试用新构造的请求,重发一次没有副作用。幂等写才敢重试,这是前提。
复盘
- 「S3 兼容」是营销词,不是契约。共识只到 SigV4 签名加基本 GET/PUT;寻址风格、条件写、错误载体、连接生命周期都要按最保守假设写,并对具体后端做运行时探测,而不是编译期写死成「大家都跟 AWS 一样」;
- 兼容层必须透出原始错误。把状态码换成响应体的
<Code>/<Message>,是这次能快速看出「其实是寻址不是鉴权」的关键。连「响应体为空」都是信号——它往往意味着请求在鉴权前就被路由层拒了; - 可选特性要「探测 + 降级」,但降级得有兜底。用一次 400/NotImplemented 探测后永久降级到普通写,比预配一张各家能力表省心;前提是上层有 LWW 合并兜底,否则降掉 CAS 就是放任 lost update;
- 幂等写才敢重试,重试只认网络异常。整对象覆盖 + 每次重建请求 = 幂等;把 4xx/5xx 排除在重试外,别把服务端的确定性错误当瞬态无谓刷。递增退避 + 上限 3 次 + 排除用户取消,是「够用不过度」的默认;
- 同一个坑要在所有端对齐。跨端 provider 的行为一致性得主动维护——一端修了另一端还裸奔,是多端产品最容易积的债。
留言