ControlRookie
返回文章

第18篇_Client 07|超时、断线、重试和真机验证怎样收口

超时不是一个统一故障。连接超时、发送失败、响应超时和协议错误需要不同恢复策略,自动重试也必须满足幂等和次数边界。

超时不是一个统一故障。连接超时、发送失败、响应超时和协议错误需要不同恢复策略,自动重试也必须满足幂等和次数边界。

适合谁收藏

  • 正在让 PLC 主动访问 HTTP 服务的工程师。
  • 需要处理请求构造、响应边界、超时与连接复用的人。
  • 希望把 Client 故障定位到确定状态和错误出口的读者。

客户端篇,第 7/7 篇;主系列第 18/28 篇。

现场问题

最危险的处理方式,是 Client 一报错就立即重发。对于读取类请求可能只是重复流量,对于带动作的 POST,则可能让服务端执行两次。

PLC 需要先判断事务在哪一步失败、请求是否可能已经送达、方法是否允许重试,再决定关闭、复位或等待人工处理。

先给结论

恢复流程固定为:锁存错误证据、停止当前读写、关闭失效句柄、清理事务缓冲区、回到可控状态。是否重试由上层策略决定,协议栈不无条件重复业务请求。

Client 07|超时、断线、重试和真机验证怎样收口
Client 07|超时、断线、重试和真机验证怎样收口

读图重点

这张图只压缩本篇的判断路径。读图时先找“连接失败”对应的输入边界,再沿着“对端返回不可解析消息”检查状态怎样推进,最后用“记录原始响应并停止盲重试”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
连接失败请求尚未发送可按次数和退避策略重试
发送中断服务端是否收到不确定保留请求标识并谨慎重试
响应超时请求可能已执行先查询结果或按业务幂等判断
协议错误对端返回不可解析消息记录原始响应并停止盲重试

从协议约束到代码职责

协议约束

恢复流程固定为:锁存错误证据、停止当前读写、关闭失效句柄、清理事务缓冲区、回到可控状态。是否重试由上层策略决定,协议栈不无条件重复业务请求。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。

工程抽象

当前工程暴露超时、NBS 错误、HTTP 错误、最近响应和 ClientMetrics。真机测试应覆盖端口拒绝、服务端延迟、半响应、主动关闭和恢复后的下一次正常请求。

文章只把“自动重试”作为有前提的策略,不把它写成默认最佳实践。工业现场更看重动作唯一性和可追溯性。

  • 连接失败:TCP 尚未建立,请求字节没有离开 PLC。记录目标地址、端口、连接状态和底层错误后,可以按限定次数与退避时间重连。
  • 发送中断:部分字节可能已经到达服务端,不能仅凭 Write 失败判断业务没有执行。必须保留请求标识、已发送长度和失败时刻,再由上层决定是否补偿。
  • 响应超时:请求可能已经完整送达并被处理,只是响应没有在期限内返回。GET 可在策略允许时重试;带动作的 POST 应先查询结果或依赖幂等键,禁止直接重复执行。
  • 协议错误:连接存在但报文不能被 Parser 接受。此时保存原始响应、解析阶段和 HTTP 错误码,关闭当前连接并停止自动重试,避免同一坏报文反复消耗扫描周期。

程序单元

本篇主证据来自 FB_HttpClient.st 中以 E_HttpClientState.iFault 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“先判断失败阶段,再谈重试。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。

本篇核心源码片段

下面两段代码来自同一个真实文件 FB_HttpClient.st,以 E_HttpClientState.iFault 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“请求尚未发送”怎样进入对象,以及“记录原始响应并停止盲重试”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“恢复前必须保留错误和原始报文证据。”,就不能把局部代码截图当成实现证据。

片段一:入口、声明与前置条件

iecst
        hConnection := hConnection,
        szSize      := 0,
        pData       := ADR(aTxBuf)
        );
    tonWrite(
        IN := FALSE,
        PT := GVL_Http.cnWriteTimeout
        );
    M_Reset();
    RETURN;
END_IF

bDone := FALSE;

CASE eState OF
    E_HttpClientState.iDisabled:
        eState := E_HttpClientState.iIdle;

    E_HttpClientState.iIdle:
        bBusy := FALSE;
        IF rtrigSend.Q THEN
            M_PrepareRequest();
            stMetrics.udiRequestCount := stMetrics.udiRequestCount + 1;
            IF NOT bRequestQueued THEN
                eState := E_HttpClientState.iFault;
            ELSIF (hConnection <> 0) AND bTcpConnected AND NOT stRequest.bConnectionClose THEN
                bReuseAttempt := TRUE;
                eState := E_HttpClientState.iSend;
            ELSE
                bReuseAttempt := FALSE;
                eState := E_HttpClientState.iTcpConnect;
            END_IF
        END_IF

    E_HttpClientState.iTcpConnect:
        bBusy := TRUE;
        fbTcpClient(
            xEnable     := TRUE,
            udiTimeOut  := udiTimeOut,
            ipAddr      := ipServer,
            uiPort      := uiEffectivePort,
            eError      => eTcpError,
            hConnection => hConnection
            );
        bTcpConnected := fbTcpClient.xActive AND (hConnection <> 0);
        IF fbTcpClient.xError THEN
            eLastNbsError := eTcpError;
            stMetrics.udiConnectErrorCount := stMetrics.udiConnectErrorCount + 1;
            M_SetClientError(
                eError   := E_HttpError.iTcpClientFailed,
                sMessage := 'TCP connect failed'
                );
            eState := E_HttpClientState.iFault;
        ELSIF bTcpConnected THEN
            eState := E_HttpClientState.iSend;
        ELSIF (udiNowMs <> 0) AND ((udiNowMs - udiStateEnterMs) >= udiTimeoutMs) THEN
            M_SetClientError(
                eError   := E_HttpError.iTimeout,
                sMessage := 'TCP connect timeout'

这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。

片段二:状态推进、边界与输出

iecst
                );
            eState := E_HttpClientState.iFault;
        END_IF

    E_HttpClientState.iSend:
        bBusy := TRUE;
        fbTcpClient(
            xEnable     := TRUE,
            udiTimeOut  := udiTimeOut,
            ipAddr      := ipServer,
            uiPort      := uiEffectivePort,
            eError      => eTcpError,
            hConnection => hConnection
            );
        M_ServiceWrite();
        IF (NOT bWriteBusy) AND (NOT bWriteExecute) THEN
            eState := E_HttpClientState.iReceive;
        END_IF

    E_HttpClientState.iReceive:
        bBusy := TRUE;
        fbTcpClient(
            xEnable     := TRUE,
            udiTimeOut  := udiTimeOut,
            ipAddr      := ipServer,
            uiPort      := uiEffectivePort,
            eError      => eTcpError,
            hConnection => hConnection
            );
        M_ServiceRead();
        M_ProcessResponse();
        IF (udiNowMs <> 0) AND ((udiNowMs - udiStateEnterMs) >= udiTimeoutMs) THEN
            M_SetClientError(
                eError   := E_HttpError.iTimeout,
                sMessage := 'HTTP response timeout'
                );
            eState := E_HttpClientState.iFault;
        END_IF

    E_HttpClientState.iDone:
        bBusy := FALSE;
        bDone := TRUE;
        IF stRequest.bConnectionClose OR xCloseConnection THEN
            fbTcpClient(
                xEnable     := FALSE,
                ipAddr      := ipServer,
                uiPort      := uiEffectivePort,
                hConnection => hConnection
                );
            bTcpConnected := FALSE;
        ELSE
            fbTcpClient(
                xEnable     := TRUE,
                udiTimeOut  := udiTimeOut,
                ipAddr      := ipServer,
                uiPort      := uiEffectivePort,
                eError      => eTcpError,
                hConnection => hConnection
                );

第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。

验证路径

场景操作通过口径
端口拒绝无监听服务连接错误并可恢复
响应延迟超过设定超时Timeout 证据完整
中途断开Body 未收齐时关闭不输出成功
故障后恢复服务恢复再触发请求旧状态不污染新事务

场景 1:端口拒绝

把目标端口改为确定没有服务监听的端口,触发一次 GET。Client 应停在连接阶段,bDone 不得出现,连接错误与失败计数只锁存一次;复位后句柄回到无效值,状态重新进入 Idle。这个场景证明请求尚未发送时可以安全重连。

场景 2:响应延迟

让测试服务接收请求后故意延迟响应,延迟时间必须大于 Client 的接收超时。检查状态先进入 Receive,再以 iTimeout 收口,同时保留已发送请求和等待时长;不能因为没有响应就把本次事务记为未发送,也不能在协议栈内部自动重放 POST。

场景 3:中途断开

让服务端声明一个确定的 Content-Length,只发送部分 Body 后主动断开。Parser 必须保持消息未完成,Client 输出传输或协议错误,已有半包不能进入业务响应;关闭并清理后,接收缓冲区长度应回到零。

场景 4:故障后恢复

在完成一次端口拒绝或中途断开后恢复服务,再由上层明确触发一笔新 GET。新事务应建立新连接、重新累计 Tx/Rx,并得到合法状态码与完整 Body;旧错误可供历史诊断,但不得继续拉高 bError 或污染新响应。

常见误判

  • 任何错误都立即重试,没有判断请求是否可能已经送达服务端。
  • 延长超时掩盖状态机没有推进,最终只是让故障更晚暴露。
  • 故障后直接发下一条请求,没有先关闭句柄和清理接收缓冲区。

这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。

这一篇你最该记住

  • 先判断失败阶段,再谈重试。
  • POST 超时不能默认安全重发。
  • 恢复前必须保留错误和原始报文证据。

系列导航

  • 系列:CodeSys HTTP 系列教程,第 18/28 篇。
  • 阶段:客户端篇,职责线位置 7/7。
  • 上一篇:第17篇
  • 下一篇:第19篇
  • 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。

↑ ↓