PAYATHON 2026

http-proxy-middleware 代理周期性出现 ECONNRESET

架构老王

结论

这次 ECONNRESET 与 React、http-proxy-middleware 以及浏览器到 Express 的连接无关。根据堆栈,连接重置发生在 Express 发起的以下链路中:

Express/axios → HTTPS/TLS → cafe.naver.com

日志中的证据包括:

AxiosError: read ECONNRESET
cause: Error: read ECONNRESET
_host: 'cafe.naver.com'
secureEstablished: false
[Symbol(kBytesRead)]: 0

这说明连接在 TLS 建连阶段被对端服务器、负载均衡器、防火墙或中间网络设备主动重置。Naver 也可能限制非浏览器流量、频繁抓取或异常 TLS 会话,但仅凭这段日志,还无法判断究竟是哪一层发送了 RST。

另一个值得排查的线索是,问题容易在空闲后出现,而重启 Node 进程能暂时恢复。这可能与 Node.js HTTPS Agent 缓存的 TLS session 有关,例如缓存的 session 与对端部分节点兼容不佳。可以针对该请求禁用 TLS session 缓存,同时设置超时、有限重试和正确的错误响应。

为什么与前端代理无关

http-proxy-middleware 负责的是:

Browser → localhost:3000 → localhost:3001

实际失败发生在后端收到请求后,由以下代码发起的外部 HTTPS 请求中:

axios.get("https://cafe.naver.com/ArticleList.nhn", ...)

修改下面的配置无法修复这条外部连接:

connection: "keep-alive"

它只影响前端开发服务器与 Express 之间的代理连接,不能控制 axios 连接 cafe.naver.com:443 时使用的 TLS socket。

同样,把:

/api/naver/cafe/385

改成:

http://localhost:3000/api/naver/cafe/385

只是换了一种浏览器访问开发服务器的写法,不会改变后端连接 Naver 的方式。

日志还显示:

keepAlive: false
Connection: close
reusedSocket: false

因此,这次失败不像是 axios 复用了已经失效的 HTTP keep-alive socket。不过,TLS session 缓存与 HTTP keep-alive 并不是一回事。即使每次都新建 TCP 连接,Node.js 仍可能尝试恢复缓存的 TLS session。

建议的处理方式

1. 为 Naver 请求使用独立的 HTTPS Agent

可以先禁用 TLS session 缓存,以规避 TLS session 恢复与对端节点可能存在的兼容问题:

const https = require("https");
const axios = require("axios");

const naverAgent = new https.Agent({
  keepAlive: false,
  maxCachedSessions: 0,
});

然后将它传给 axios:

const result = await axios.get(
  "https://cafe.naver.com/ArticleList.nhn",
  {
    httpsAgent: naverAgent,
    params: {
      "search.clubid": 10050146,
      "search.menuid": request.params.menu_id,
      "search.boardType": "L",
      userDisplay: 10,
    },
    headers: {
      Accept: "text/html,application/xhtml+xml",
      "User-Agent":
        "Mozilla/5.0 (compatible; PersonalCrawler/1.0; +http://localhost)",
    },
    responseType: "arraybuffer",
    timeout: 10_000,
  }
);

maxCachedSessions: 0 不能解决所有 ECONNRESET。它主要用于排查和处理这类现象:空闲后的首次请求失败,重启进程后又暂时恢复,而问题可能与 TLS session 状态有关。如果禁用缓存后问题依旧存在,原因更可能是对端限流、反爬策略或网络路径不稳定。

这个请求使用 GET,没有 JSON body,因此不应发送:

Content-Type: application/json

设置符合实际响应内容的 Accept 更合适。

2. 对临时网络错误进行有限重试

ECONNRESET 属于网络层临时故障。即使客户端配置没有问题,远端服务器也可能在负载切换、限流或清理连接时重置连接。生产代码需要处理外部请求偶尔失败的情况。

可以只重试幂等请求和明确的临时错误:

const RETRYABLE_CODES = new Set([
  "ECONNRESET",
  "ETIMEDOUT",
  "ECONNABORTED",
  "EAI_AGAIN",
]);

const delay = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function getWithRetry(url, config, maxAttempts = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      return await axios.get(url, config);
    } catch (error) {
      lastError = error;

      const retryable =
        RETRYABLE_CODES.has(error.code) ||
        error.response?.status === 429 ||
        (error.response?.status >= 500 &&
          error.response?.status < 600);

      if (!retryable || attempt === maxAttempts) {
        throw error;
      }

      const backoff = 300 * 2 ** (attempt - 1);
      const jitter = Math.floor(Math.random() * 200);
      await delay(backoff + jitter);
    }
  }

  throw lastError;
}

调用方式如下:

const result = await getWithRetry(
  "https://cafe.naver.com/ArticleList.nhn",
  {
    httpsAgent: naverAgent,
    params: {
      "search.clubid": 10050146,
      "search.menuid": request.params.menu_id,
      "search.boardType": "L",
      userDisplay: 10,
    },
    headers: {
      Accept: "text/html,application/xhtml+xml",
      "User-Agent":
        "Mozilla/5.0 (compatible; PersonalCrawler/1.0; +http://localhost)",
    },
    responseType: "arraybuffer",
    timeout: 10_000,
  },
  3
);

不要无限重试。不断刷新或高频重试可能让远端限流变得更严重。

3. 返回正确的后端错误状态

当前的错误处理方式是:

catch (error) {
  response.send(error);
}

这里有两个问题:

  • 默认情况下可能仍然返回 HTTP 200,导致前端无法准确区分成功与失败。
  • 直接发送完整的 Axios 错误对象,可能泄露请求配置、内部结构或敏感 header。

可以改为:

catch (error) {
  console.error("Naver request failed", {
    code: error.code,
    message: error.message,
    status: error.response?.status,
  });

  if (!response.headersSent) {
    response.status(502).json({
      error: "UPSTREAM_REQUEST_FAILED",
      message: "暂时无法从上游站点获取数据",
    });
  }
}

502 Bad Gateway 表示 Express 已正常接收客户端请求,但访问上游站点失败,符合这里的实际情况。

整合后的路由示例

const https = require("https");
const axios = require("axios");
const cheerio = require("cheerio");

const naverAgent = new https.Agent({
  keepAlive: false,
  maxCachedSessions: 0,
});

const RETRYABLE_CODES = new Set([
  "ECONNRESET",
  "ETIMEDOUT",
  "ECONNABORTED",
  "EAI_AGAIN",
]);

const delay = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function requestNaver(menuId, maxAttempts = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      return await axios.get(
        "https://cafe.naver.com/ArticleList.nhn",
        {
          httpsAgent: naverAgent,
          params: {
            "search.clubid": 10050146,
            "search.menuid": menuId,
            "search.boardType": "L",
            userDisplay: 10,
          },
          headers: {
            Accept: "text/html,application/xhtml+xml",
            "User-Agent":
              "Mozilla/5.0 (compatible; PersonalCrawler/1.0; +http://localhost)",
          },
          responseType: "arraybuffer",
          timeout: 10_000,
        }
      );
    } catch (error) {
      lastError = error;

      const retryable =
        RETRYABLE_CODES.has(error.code) ||
        error.response?.status === 429 ||
        (error.response?.status >= 500 &&
          error.response?.status < 600);

      if (!retryable || attempt === maxAttempts) {
        throw error;
      }

      await delay(
        300 * 2 ** (attempt - 1) +
          Math.floor(Math.random() * 200)
      );
    }
  }

  throw lastError;
}

app.get("/:menu_id", async (request, response) => {
  try {
    const upstream = await requestNaver(
      request.params.menu_id
    );

    const decoder = new TextDecoder("EUC-KR");
    const content = decoder.decode(upstream.data);
    const $ = cheerio.load(content);
    const list = $(
      "div.article-board:nth-of-type(4)>table>tbody>tr"
    );

    const articles = [];

    list.each((_, tag) => {
      const href = $(tag)
        .find("a.article")
        .attr("href");

      if (!href) {
        return;
      }

      articles.push({
        number: $(tag)
          .find("div.inner_number")
          .text()
          .trim(),
        title: $(tag)
          .find("a.article")
          .contents()
          .last()
          .text()
          .trim(),
        author: $(tag)
          .find(".p-nick>a")
          .text()
          .trim(),
        date: $(tag)
          .find("td.td_date")
          .text()
          .replace(
            /(\d{4}).(\d{2}).(\d{2})./g,
            "$2-$3"
          ),
        url: new URL(
          href,
          "https://cafe.naver.com"
        ).href,
      });
    });

    response.json(articles);
  } catch (error) {
    console.error("Naver request failed", {
      code: error.code,
      message: error.message,
      status: error.response?.status,
    });

    response.status(502).json({
      error: "UPSTREAM_REQUEST_FAILED",
      message: "暂时无法从上游站点获取数据",
    });
  }
});

还需要检查的事项

建议升级到仍受支持的 Node.js LTS 版本,同时升级 axios。Node.js 18.10.0 已经很旧。升级不能保证远端不再重置连接,但可以排除旧版 TLS、DNS 和 HTTP 客户端实现中已经修复的问题。

如果问题依然存在,可以记录以下信息,以便继续定位:

console.error({
  timestamp: new Date().toISOString(),
  code: error.code,
  syscall: error.syscall,
  address: error.address,
  status: error.response?.status,
  reusedSocket: error.request?._currentRequest?.reusedSocket,
});

还需要确认:

  • 问题是否只出现在某个网络环境、容器或云服务器出口。
  • 同一时间使用 curl 请求 Naver 时,连接是否也会被重置。
  • 是否存在短时间并发抓取或重复请求。
  • 上游是否要求 Cookie、Referer、登录状态或特定访问流程。
  • HTML 页面结构或访问策略是否发生了变化。
  • 抓取行为是否符合目标站点的服务条款、访问规则和频率限制。

远端连接重置无法由客户端彻底禁止。更可靠的处理方式是识别上游故障并设置超时,只对幂等请求进行少量退避重试,最终失败时再向前端返回明确的 502。对于这次出现的特定现象,禁用 TLS session 缓存可以作为重点排查和临时规避手段。

备注:内容仅供参考。