编程 curl 报 65 CURLE_SEND_FAIL_REWIND:PHP 8.6 用 CURLOPT_SEEKFUNCTION 让流式请求体可回卷

2026-11-03 21:02:16

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 回调,还是退回临时文件方案。

复制全文 生成海报 PHP curl 接口对接

推荐文章

程序员茄子在线接单