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 缓存可以作为重点排查和临时规避手段。
备注:内容仅供参考。
