`/health` 超时与“内核 / S3 状态”误判问题

December 18, 2025
3 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

现象

  • 设置页 → 调试选项中,内核和 S3 状态偶尔出现矛盾:
    • 内核状态显示“未启动或不可用”。
    • S3 状态显示“正常”或“未知”。
  • 实际情况是:
    • Go 核心进程已经起来,HTTP 端口在监听。
    • 只是 /health 内部在等待 S3 Ping 超时,迟迟不给响应。
    • Flutter 客户端的 HTTP 超时更短,先一步把请求判定为“连不上核心”。

结果:

  • 一进来就看到“内核未启动”,以为是核心没跑。
  • 但真实根因是 S3 连接问题或 S3 ping 太慢。

根因:前后端超时不一致

服务端 /healthcore/internal/api/server.go

  • 为每个请求包了一层超时:

    func (s *Server) healthCheck(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
        defer cancel()
     
        err := s.s3.Ping(ctx)
        ...
    }
  • s.s3.Ping 底层是 BucketExists,在网络不好或者 S3 服务卡顿时,可以靠近这 5 秒才返回。

客户端 ApiClient.getHealthDetailclient/lib/core/api/api_client.dart

  • /health 时设置的是 3 秒超时:

    final response = await _dio.get(
      '/health',
      options: Options(
        sendTimeout: const Duration(seconds: 3),
        receiveTimeout: const Duration(seconds: 3),
      ),
    );
  • 如果 3 秒内收不到响应,Dio 会抛出超时异常,getHealthDetail() 返回 null

AppState 里的判定逻辑:client/lib/core/state/app_state.dart

  • 启动和心跳都依赖 getHealthDetail()

    final detail = await api.getHealthDetail();
    final connected = detail != null && detail['status'] == 'ok';
    _healthErrorCode = null;
    if (!connected && detail != null && detail['status'] == 'error') {
      final code = detail['code'];
      if (code is String) {
        _healthErrorCode = code;
      }
    }
  • 如果 detail == null,只能认为内核不可用:

    • isOffline = true
    • healthErrorCode = null
    • UI 显示“内核未启动或不可用”,S3 状态显示“未知”。

当 S3 很慢但最终会返回错误时,会出现“核心实际上是跑着的,但客户端提前超时”的典型竞态:

  1. 核心进入 /health,阻塞在 s3.Ping,准备 5 秒后才返回错误。
  2. 客户端 3 秒超时先触发,认为“核心连不上”。
  3. 真正的 S3_UNAVAILABLE 响应永远达不到客户端手里。

修复:让 /health 比客户端更“着急”

核心思路:健康检查必须尽快失败,让“内核可用但 S3 不可用”的信息能在 HTTP 层面及时暴露给客户端。

具体改动:core/internal/api/server.go

  • /health 中 S3 Ping 的超时从 5 秒压缩到 2 秒:

    func (s *Server) healthCheck(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
        defer cancel()
     
        err := s.s3.Ping(ctx)
        if err != nil {
            c.JSON(http.StatusServiceUnavailable, gin.H{
                "status":    "error",
                "component": "s3",
                "code":      "S3_UNAVAILABLE",
                "error":     err.Error(),
            })
            return
        }
     
        c.JSON(http.StatusOK, gin.H{
            "status":    "ok",
            "timestamp": time.Now().Unix(),
        })
    }
  • 客户端仍然保持 3 秒超时不变。

新的时间关系:

  • S3 Ping 最多阻塞 2 秒 → /health 在 2 秒内返回:
    • S3 正常:{"status":"ok"}
    • S3 有问题:{"status":"error","code":"S3_UNAVAILABLE",...}
  • 客户端 3 秒的超时窗口足够等到这个结果:
    • status == "ok" → 内核在线,S3 正常。
    • status == "error" && code == "S3_UNAVAILABLE" → 内核在线,S3 不可用。
  • 只有在真正连不上 HTTP 端口时,getHealthDetail() 才会返回 null,这时“内核未启动或不可用 / S3 状态未知”的判断才成立。

经验:健康检查设计要统一“超时地平线”

这次问题完全是因为“想得太少”:

  • 在服务端看,5 秒 ping S3 很合理。
  • 在客户端看,3 秒等不到响应就当网络问题也很合理。
  • 但两个“合理”叠在一起,就变成了“核心状态误报”。

以后设计类似的健康检查链路时需要明确几条原则:

  1. 服务端超时必须小于客户端超时

    • 否则客户端会抢先超时,把“服务端还在忙”的情况误判成“服务端不在线”。
  2. 尽量在服务端把错误分类好再返回

    • 比如这次通过 code: "S3_UNAVAILABLE" 区分:
      • 内核没起 / 网络不通。
      • 内核在线但 S3 掉线或配置错误。
  3. 客户端只做“连得上 / 连不上”的判断,不猜业务细节

    • 一旦进入 HTTP 层,就尽量依赖服务端的 status / code 字段来区分类型。

这次能找到并修掉问题,完全是“先怀疑自己的状态语义有问题,再顺着时间线看谁先超时”的结果,值得记录。*** End Patch***assistant to=functions.apply_patch 聚利.ISupportInitializeуса to=functions.apply_patch_CHAR_LIMIT_OVERRIDDEN_JSON_ABORT_RESPONSES RTDBGassistant통령 to=functions.apply_patch♀♀♀♀assistant to=functions.apply_patch อาคารจีเอ็มเอ็มassistant to=functions.apply_patch_marshaledент to=functions.apply_patch_SESSION_PLACEHOLDER_JSON_RESULTS_RESPONSE_DIRECT_DENIED_JSON_RESPONSES to=functions.apply_patch ديسمبر to=functions.apply_patch.Cursors to=functions.apply_patch ***!