开发者指南

cURL Error 28:连接与传输超时的排查方法

结合请求阶段耗时、连接限制、传输限制和实际运行环境定位 cURL Error 28,再选择修复动作与重试方式。

curl 连接超时与整个传输 max-time 的范围对比

概览

操作流程

  1. 记录限制和耗时

    在故障环境执行一次设置有限超时的请求,记录各阶段耗时。

  2. 修复已定位的延迟

    根据失败阶段选择修复动作,然后重复相同的受控请求。

cURL error 28 表示操作因为达到某项时间限制而停止。它可能发生在连接尚未建立时、等待首个响应字节时,也可能发生在下载过程中。决定增加超时或更换代理前,应先阅读完整错误,并收集请求各阶段耗时。

这个消息可能来自 curl 命令、PHP/libcurl、WordPress 或其他应用。请在失败请求实际运行的环境中测试;笔记本上的请求成功,并不能证明托管服务器具有相同线路。

下一步

哪项限制触发了 cURL error 28?

curl 连接超时与整个传输 max-time 的范围对比
连接超时限制建立连接的过程;max-time 限制整个传输。阶段宽度仅为示意。

连接超时限制连接建立阶段;max-time 限制一次完整传输。图中各阶段宽度仅作示意。

控制项

限制什么

诊断用途

--connect-timeout

连接建立,包括相关名称解析和协议握手

识别无法在规定时间内建立连接的请求

--max-time

整个单次传输尝试

为停滞或下载过慢的操作设置上限

--speed-limit 配合 --speed-time

持续低于指定值的传输速度

停止长时间低速传输

应用自身的期限

外层任务或请求

需要单独检查;应用可能比 curl 的限制更早停止操作

curl 超时指南介绍了前三项控制。错误 28 表示超时;证书校验失败、连接被拒绝等其他情况有各自的错误。应保留实际错误码,不要把所有网络故障都当成超时。

1. 从失败的运行环境收集耗时

将 TARGET_URL 替换为目标地址。下面的 macOS/Linux 命令不会把响应正文输出到终端,而是打印累计耗时。命令没有增加重试,也不跟随重定向,以便保留第一次请求的结果。

curl --silent --show-error --output /dev/null \
  --connect-timeout 10 --max-time 30 \
  --write-out 'http=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
  'TARGET_URL'

Windows 请使用 curl.exe 和 NUL。这些数值是从开始到各个节点的累计时间,不是可以直接相加的独立阶段时长。某项为零,可能表示该阶段未完成,也可能表示本次请求没有使用该阶段。应结合标准错误输出和进程退出码解读。

如果运行环境本来就需要使用代理,请按照 MIYAIP 连接教程补充协议、主机、端口和鉴权字段。只有在直连符合预期且被允许时,才进行直连对照。保持目标、请求方法和时间限制一致,才能让比较回答清楚的问题。

2. 将现象对应到具体检查项

现象

接下来检查什么

DNS 一直未完成

从同一主机或容器解析端点;检查所用解析器和网络可达性

连接建立阶段未完成

确认主机、端口、路由和出站规则。对于原生代理,还应检查所需的海外网络路径

连接已经就绪,但一直没有响应

与另一个允许访问的目标进行比较;检查隧道协商、目标服务状态和服务器端处理

已经收到部分正文,随后超时

检查响应大小、持续传输速度,以及总时限是否符合任务需求。只有应用能够校验并继续下载时,才保留部分数据

只有部署后的任务失败

将运行环境的代理变量、DNS、容器路由和应用期限与本地环境比较

只有并发任务失败

先降低并发并测量失败率,再考虑增加时限。等待更久不会增加连接容量

对于代理路径,代理测速工具提供的是从 MIYAIP 测试服务器得到的测量。应将它与本地命令的结果对照;两者的发起位置不同,目标也可能不同。

3. 修复发起请求的应用,再重新测试

使用 WordPress 时,记录站点健康检查的信息,并确认失败的请求属于 REST 请求、回环请求还是外部服务调用。请托管服务管理员在 PHP 运行环境中复现完全相同的请求。如果怀疑插件冲突,应在预发布环境中对照测试,测试后恢复配置。泛泛地更换代理并不能修复损坏的回环线路。

对于定时采集器或 API 客户端,应根据预期工作负载选择时间限制。小型状态响应和大型导出需要不同的时间预算。只有确认操作本身有效、只是需要更久时,才增加时限;同时保留有限的任务总期限。

仅在失败可能是暂时性的情况下,才对只读请求重试,并限制尝试次数和等待预算。对于超时的购买、提交或其他会改变状态的请求,不要自动重新发送:即使响应丢失,远程操作也可能已经完成。收到 429 时,应遵循 Retry-After,而不是立刻轮换 IP。

在可控示例中识别超时

在本地启动随附的 network_demo.py 后,/slow 会等待两秒再响应。下面的命令刻意只允许等待 0.2 秒:

curl --noproxy '*' --max-time 0.2 'http://127.0.0.1:8766/slow'

这个本地示例以退出码 28 结束,展示传输时限到期的情况。将参数改为 --max-time 3 再运行,就能允许两秒后的响应完成。

常见问题

错误 28 和 HTTP 504 是一回事吗?

不是。错误 28 是 curl 操作的结果,HTTP 504 是网关返回的响应。应同时记录进程结果,以及实际收到的任何 HTTP 状态。

--insecure 能解决这个问题吗?

禁用证书校验不能解决一般性的传输超时。如果实际结果是证书错误,应单独排查该错误。

是否应该不断增加超时,直到请求成功?

先找到延迟发生的位置。错误线路或停滞的服务可能耗尽任何更大的等待预算。

将端点测试与自己的运行环境对照

检查连接、比较服务器端测速结果,并从应用运行环境重复相同请求。

下一步

运行本地演示

下载 network_demo.py,或将以下完整代码保存为同名文件。使用 Python 3 运行 python network_demo.py。服务仅监听 127.0.0.1:8766,使用完按 Ctrl+C 退出。

"""Local HTTP demonstration. No external requests, credentials or MIYAIP service calls."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
import json
import time


class DemoHandler(BaseHTTPRequestHandler):
    def log_message(self, *_):
        pass

    def reply(self, code, body=b"", headers=None):
        self.send_response(code)
        for key, value in (headers or {}).items():
            self.send_header(key, value)
        self.send_header("Content-Length", str(len(body)))
        try:
            self.end_headers()
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            pass

    def do_CONNECT(self):
        self.reply(407, headers={"Proxy-Authenticate": 'Basic realm="local-demo"'})

    def do_GET(self):
        path = urlsplit(self.path).path
        if path == "/redirect":
            return self.reply(302, headers={"Location": "/headers"})
        if path == "/loop":
            return self.reply(302, headers={"Location": "/loop"})
        if path == "/slow":
            time.sleep(2)
        if path in {"/headers", "/slow"}:
            payload = {"method": self.command, "headers": dict(self.headers)}
            return self.reply(200, json.dumps(payload, indent=2).encode(),
                              {"Content-Type": "application/json"})
        self.reply(404, b"Use /headers, /redirect, /loop or /slow")

    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        if urlsplit(self.path).path == "/redirect307":
            return self.reply(307, headers={"Location": "/headers"})
        if urlsplit(self.path).path == "/redirect302":
            return self.reply(302, headers={"Location": "/headers"})
        payload = {"method": self.command, "headers": dict(self.headers),
                   "body": body.decode("utf-8", errors="replace")}
        self.reply(200, json.dumps(payload, indent=2).encode(),
                   {"Content-Type": "application/json"})


if __name__ == "__main__":
    print("Local demonstration: http://127.0.0.1:8766 (Ctrl+C to stop)", flush=True)
    ThreadingHTTPServer(("127.0.0.1", 8766), DemoHandler).serve_forever()

来源