curl 报 CURLE_SEND_FAIL_REWIND(65):PHP 8.6 用 CURLOPT_SEEKFUNCTION 让流式请求体可回卷
现象:错误码 65
对接上传接口时,如果请求体是流式发送的(CURLOPT_UPLOAD + CURLOPT_INFILE,或者用 CURLOPT_READFUNCTION 逐段吐数据),服务端一旦要求重发,curl 就可能直接失败:
curl error 65: Send failed since rewinding of the data stream failed
CURLE_SEND_FAIL_REWIND 就是 65。含义很直白:这次发送需要把请求体倒回起点重来,libcurl 倒不回去,于是放弃。
哪些情况会重发 body
重发是 HTTP 客户端很正常的动作,不是异常:
- 307 / 308 重定向:这两个状态码明确要求保留原方法、原请求体重发到新地址。
- 401 + 多轮认证:Digest / NTLM / Negotiate 这类认证需要先拿 challenge,再带着凭据重发一次请求,body 必须再来一遍。
- 复用的连接被服务端关闭:curl 发现连接已被对端关掉时,会在新连接上重试请求,同样需要重发 body。
这些场景本来都该对业务透明。但前提是 libcurl 能把“读指针”拨回起点。
旧版本为什么必挂
流式 body 的模型是“读到哪算哪”:libcurl 只知道自己从回调里拿到了字节,并不知道这些字节还能不能再要一遍。重发时它会尝试 rewind 这个流。PHP 之前没有任何途径把流重置回去,于是 libcurl 只能报 65。
也就是说,问题不在网络、不在服务端,而在客户端无法提供回卷能力。
CURLOPT_SEEKFUNCTION:注册一个回卷回调
PHP 8.6 新增 CURLOPT_SEEKFUNCTION,用来注册“把请求体重定位到某个偏移”的回调。回调签名是:
function (CurlHandle $ch, int $offset, int $origin): int
$ch:当前 curl 句柄。$offset:目标位置。$origin:seek 的基准(SEEK_SET/SEEK_CUR/SEEK_END),需要按它换算出绝对偏移。
返回值必须是下面三个常量之一:
| 返回值 | 含义 |
|---|---|
CURL_SEEKFUNC_OK | 已成功定位,libcurl 可以继续重发 |
CURL_SEEKFUNC_FAIL | 定位失败(比如 fseek 返回非 0),本次发送失败 |
CURL_SEEKFUNC_CANTSEEK | 这个流根本不支持回卷,libcurl 不做 rewinding 重试 |
从文件句柄发 body 时,回调就是一个 fseek:
$path = '/data/upload.bin';
$fh = fopen($path, 'rb');
$ch = curl_init('https://api.example.com/upload');
curl_setopt_array($ch, [
CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $fh,
CURLOPT_INFILESIZE => filesize($path),
CURLOPT_SEEKFUNCTION => static function (CurlHandle $ch, int $offset, int $origin) use ($fh): int {
// 只处理从起点定位;其他基准直接说明不支持
if ($origin !== SEEK_SET) {
return CURL_SEEKFUNC_CANTSEEK;
}
if (fseek($fh, $offset, SEEK_SET) !== 0) {
return CURL_SEEKFUNC_FAIL;
}
return CURL_SEEKFUNC_OK;
},
]);
curl_exec($ch);
fclose($fh);
注册之后,307/308、401 多轮认证、连接重试这些路径都会先调用该回调把流拨回起点,再重新读取 body,不会再落到 65。
如果流本来就 seek 不了
STDIN、管道、socket 这类只进流,读过就没了,回卷不可能实现。有两条退路:
其一,明确声明不支持。 回调无条件返回 CURL_SEEKFUNC_CANTSEEK,告诉 libcurl 别做 rewinding 重试。这样失败原因会还原成原本的重定向或认证问题,而不是含糊的“回卷失败”。
curl_setopt($ch, CURLOPT_SEEKFUNCTION,
static fn (CurlHandle $ch, int $offset, int $origin): int => CURL_SEEKFUNC_CANTSEEK);
其二,先落地成临时文件。 把只能顺序读的源写进临时文件,再从文件流发送,seek 回调对文件做 fseek 即可,重发能力完整保留。
$tmp = tmpfile();
stream_copy_to_stream(STDIN, $tmp);
$size = ftell($tmp);
rewind($tmp);
curl_setopt_array($ch, [
CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $tmp,
CURLOPT_INFILESIZE => $size,
CURLOPT_SEEKFUNCTION => static function (CurlHandle $ch, int $offset, int $origin) use ($tmp): int {
return fseek($tmp, $offset, SEEK_SET) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_FAIL;
},
]);
代价是占一份临时磁盘空间,换来的是重试路径可用。
顺带:size_delivered 区分“写出去的字节”和 content-length
同版本 curl_getinfo() 的返回数组多了 size_delivered 键,表示实际传给下载写回调的总字节数;也可以作为 option 传:
$info = curl_getinfo($ch);
$delivered = $info['size_delivered']; // 数组键
$delivered = curl_getinfo($ch, CURLINFO_SIZE_DELIVERED); // 或按 option 取
它的用途是在响应被截断时把两件事分开:content-length 是服务端声明的长度,size_delivered 是真正交到写回调手上的字节。两者对不上,说明传输中途断了,而不是自己写回调少写了。
$received = 0;
curl_setopt($ch, CURLOPT_WRITEFUNCTION, static function ($ch, string $chunk) use (&$received): int {
$received += strlen($chunk);
return strlen($chunk);
});
curl_exec($ch);
$info = curl_getinfo($ch);
if ($info['size_delivered'] !== $received) {
// 写回调统计与实际交付不一致,通常意味着回调本身有问题
}
CURLINFO_SIZE_DELIVERED 需要 libcurl 8.20.0 或更高版本;低于该版本时这个键不出现。
版本前提
CURLOPT_SEEKFUNCTION由 PHP 8.6 提供。size_delivered/CURLINFO_SIZE_DELIVERED需要 libcurl 8.20.0+。
升级后先确认 curl_version()['version'],再决定是用 seek 回调,还是退回临时文件方案。