看这一段人畜无害的代码,axios的引入和配置,再加上超长的超时限制
import axios from 'axios'
let baseUrl = 'https://XXX.com的接口地址'
const request = axios.create({
baseURL: baseUrl, //基准地址
timeout: 500000
})
// 请求拦截
request.interceptors.request.use((config) => {
return config
})
// 响应拦截
request.interceptors.response.use(
(res) => {
switch (res.status) {
case '500':
// Toast.fail("服务器错误")
return
case '404':
// Toast.fail("页面丢失了~~")
}
return res
},
(error) => {
// 错误处理
if (error && error.response) {
console.log(error.response)
}
return Promise.reject(error)
}
)
export default request
那么问题来了,第一个请求之后,node处理了很多逻辑操作,以至于要等好久才会发起第二次请求,第二次请求的拦截已经可以打印config,但是后面完全没有响应了,并且报错:socket hang up
result: 'error',
data: AxiosError: socket hang up
at AxiosError.from (file:///xxx本地路径/node_modules/axios/lib/core/AxiosError.js:89:14)
at RedirectableRequest.handleRequestError (file:///xxx本地路径/node_modules/axios/lib/adapters/http.js:610:25)
at RedirectableRequest.emit (node:events:531:35)
at RedirectableRequest.emit (node:domain:488:12)
at eventHandlers.<computed> (xxx本地路径/node_modules/follow-redirects/index.js:38:24)
at ClientRequest.emit (node:events:519:28)
at ClientRequest.emit (node:domain:488:12)
at TLSSocket.socketOnEnd (node:_http_client:524:9)
at TLSSocket.emit (node:events:531:35)
at TLSSocket.emit (node:domain:488:12)
at Axios.request (file:///xxx本地路径/node_modules/axios/lib/core/Axios.js:45:41)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async GetServiceInfo (file:///xxx本地路径/src/service/index.js:26:9)
at async HandleEventList (file:///xxx本地路径/src/creatproject/HandleEventList.js:92:47)
at async HandleTreeData (file:///xxx本地路径/src/creatproject/HandlePageData.js:81:25)
at async handlePageData (file:///xxx本地路径/src/creatproject/HandlePageData.js:37:5)
at async CreatProject.creatRN (file:///xxx本地路径/src/creatproject/CreatProject.js:128:4) {
code: 'ECONNRESET',
config: {
transitional: [Object],
adapter: [Array],
transformRequest: [Array],
transformResponse: [Array],
timeout: 50000,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
maxContentLength: -1,
maxBodyLength: -1,
env: [Object],
validateStatus: [Function: validateStatus],
headers: [Object [AxiosHeaders]],
baseURL: 'https://www不能告诉你的接口地址',
url: '/zzz不能告诉你的路径',
method: 'post',
data: '{...参数}'
},
request: Writable {
_events: [Object],
_writableState: [WritableState],
_maxListeners: undefined,
_options: [Object],
_ended: true,
_ending: true,
_redirectCount: 0,
_redirects: [],
_requestBodyLength: 111,
_requestBodyBuffers: [Array],
_eventsCount: 3,
_onNativeResponse: [Function (anonymous)],
_currentRequest: [ClientRequest],
_currentUrl: 'https://www不能告诉你的接口地址/zzz不能告诉你的路径',
_timeout: null,
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false
},
cause: Error: socket hang up
at TLSSocket.socketOnEnd (node:_http_client:524:23)
at TLSSocket.emit (node:events:531:35)
at TLSSocket.emit (node:domain:488:12)
at endReadableNT (node:internal/streams/readable:1696:12)
at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
code: 'ECONNRESET'
}
}
最喜欢疑难杂症,因为比较有意思,但也最怕这种坑,因为老板总是催业务,不敢耽搁太久。
一、我们先盘一下问题
在浏览器中,Axios使用 XMLHttpRequest 对象作为其底层的 HTTP 客户端。XMLHttpRequest 是浏览器提供的原生对象,用于在客户端发起 HTTP 请求。在 Node.js 环境中,Axios使用 http 模块或 https 模块作为其底层的 HTTP 客户端,具体取决于请求的协议是 HTTP 还是 HTTPS。这些模块提供了在 Node.js 环境中发起 HTTP 请求所需的功能。因此,无论是在浏览器端还是在 Node.js 环境中,Axios都会使用适当的 Http client实现网络请求。所以我们暂且把他俩当作同一个情况。
什么是 Socket hang up
说不定以后也可以做为面试题目来提问,什么是 Socket hang up?
hang up 翻译为英文有挂断的意思, socket hang up 也可以理解为 socket(链接)被挂断。无论使用哪种语言,也许多多少少应该都会遇见过,只是不知道你有没有去思考这是为什么?例如在 Node.js 中系统提供的 http server 默认超时为 2 分钟(server.timeout 可以查看),如果一个请求超出这个时间,http server 会关闭这个请求链接,当客户端想要返回一个请求的时候发现这个 socket 已经被 “挂断”,就会报 socket hang up 错误。
问题复现
比较偶现,但是本地实际应用中,两次请求间隔时间较长,再通过调整timeout大小还是比较稳定复现的。
网上有一种复现方式有兴趣可以试试:
1、服务端:开启一个 http 服务,定义 /timeout 接口设置 3 分钟之后延迟响应,也就是让它3米分钟后再response;
2、客户端:创建一个http请求并监听请求和error;
3、操作:启动服务端之后再启动客户端大约 2 分钟之后或者直接 kill 掉服务端会报Soket hang up的错
错误原因
为什么在 http client 这一端会报 socket hang up 这个错误,看下 Node.js http client 端源码会发现由于没有得到响应,那么就认为这个 socket 已经结束,因此会触发一个 connResetException(‘socket hang up’) 错误。
// https://github.com/nodejs/node/blob/v12.x/lib/_http_client.js#L440
function socketOnEnd() {
const socket = this;
const req = this._httpMessage;
const parser = this.parser;
if (!req.res && !req.socket._hadError) {
// If we don't have a response then we know that the socket
// ended prematurely and we need to emit an error on the request.
req.socket._hadError = true;
req.emit('error', connResetException('socket hang up'));
}
if (parser) {
parser.finish();
freeParser(parser, req, socket);
}
socket.destroy();
}
二、解决问题的过程
1、设置 http server socket 超时时间(不建议)
看以下 Node.js http server 源码,默认情况下服务器的超时值为 2 分钟,如果超时,socket 会自动销毁,可以通过调用 server.setTimeout(msecs) 方法将超时时间调节大一些,如果传入 0 将关闭超时机制。
// https://github.com/nodejs/node/blob/v12.x/lib/_http_server.js#L348
function Server(options, requestListener) {
// ...
this.timeout = kDefaultHttpServerTimeout; // 默认为 2 * 60 * 1000
this.keepAliveTimeout = 5000;
this.maxHeadersCount = null;
this.headersTimeout = 40 * 1000; // 40 seconds
}
Object.setPrototypeOf(Server.prototype, net.Server.prototype);
Object.setPrototypeOf(Server, net.Server);
Server.prototype.setTimeout = function setTimeout(msecs, callback) {
this.timeout = msecs;
if (callback)
this.on('timeout', callback);
return this;
};
但是这种不受限的连接,明显不安全呀,而且也会很占用资源,这种做法我是不敢用的,哈哈哈。如果不设置 setTimeout 也可以针对这种错误在 http client 端进行捕获放入队列发起重试,当这种错误概率很大的时候要去排查相应的服务是否存在处理很慢等异常问题。
2、关闭keep-alive
这点我们针对axios的,在请求的拦截中我们看到它默认开启了keep-alive,持久连接。双方都可以发起持久连接的请求,但是双方也都可以拒绝持久连接。通俗说就是我可以请求你在我闲着的时候保持通话,但是你也可以在我闲着没声儿的时候直接挂我电话的意思,反之亦然。有大佬特别耐心地抓了网络包,并分析了tcp包的数据传输过程,有兴趣可以了解一下。,
当然服务器是支持的,而且postman发请求一直都是没问题的。只是为了避免在长连接中出现socket变化,导致ECONNRESET
问题,我们可以直接关闭,让每次请求都重新进行三次握手:
import axios from 'axios'
import https from 'https'
request.interceptors.request.use(
(config) => {
//在请求拦截中设置
config.httpsAgent = new https.Agent({ keepAlive: false });
return config;
},
(error) => {
return Promise.reject(error);
}
)
不过这样也有问题,毕竟长连接的作用本来就是减少请求时间和资源消耗,对于一些c端场景可能并不是最优解。
3、启用axios-retry(最优解)
axios-retry
是一个用于在Node.js和浏览器中使用的Axios插件,它提供了在HTTP请求失败时自动重试的功能。通过使用axios-retry
,您可以配置Axios实例以在遇到连接问题或其他临时错误时自动重试请求,以增加请求的可靠性。
同时我们在请求拦截中增加对重试请求的监听或者响应里的错误监听,通过日志来判断网络请求的稳定性和触达率。
import axios from 'axios'
import axiosRetry from 'axios-retry'
let baseUrl = 'https://XXX.com的接口地址'
const request = axios.create({
baseURL: baseUrl, //基准地址
timeout: 500000
})
// 设置axios-retry的配置
axiosRetry(request, {
retries: 3, // 设置重试次数
retryDelay: axiosRetry.exponentialDelay, // 设置重试延迟策略
});
// 请求拦截
request.interceptors.request.use((config) => {
if (config.__isRetryRequest) {
// 这里是针对重试请求的监听
logger.info('重试请求', config.url)
}
return config
})
// 响应拦截
request.interceptors.response.use(
(res) => {
// 。 。 。
return res
},
(error) => {
if (error.code === 'ECONNABORTED') {
// 监听Socket hang up的错误日志
logger.error('sockt错误', error)
} else{
// 。 。 。
}
return Promise.reject(error)
}
)
三、思考一下
除了上面提到的可行或不可行的方法,处理过程中还尝试去看node版本和axios版本对它的影响,但是都没有绝对的处理掉,并且在github上关于axios的这个问题也有讨论,并且现在的bug状态还是open,应该是还没解决。
axios源码中看到有这么一段话,翻译是说:
有时响应会很慢,如果不响应,连接事件就会被事件循环系统阻塞;
计时器回调将被触发,并在连接之前调用abort(),然后获得“socket hang up”和code码ECONNRESET;
此时,如果我们有大量的请求,nodejs会在后台挂起一些socket。这个数字会越来越大。 然后这些被挂起的套接字就会一点点地吞噬CPU;
ClientRequest.setTimeout将在指定的毫秒内触发,并且可以确保在连接后触发abort()。
它注释说明的代码是用来处理超时的,不过也从侧面反映出它抛出“socket hang up”的原因:计时器启动了,或者socket太大占内存了。
// Sometime, the response will be very slow, and does not respond, the connect event will be block by event loop system.
// And timer callback will be fired, and abort() will be invoked before connection, then get "socket hang up" and code ECONNRESET.
// At this time, if we have a large number of request, nodejs will hang up some socket on background. and the number will up and up.
// And then these socket which be hang up will devouring CPU little by little.
// ClientRequest.setTimeout will be fired on the specify milliseconds, and can make sure that abort() will be fired after connect.
在文章开始的部分我提到两次请求间隔较长,可以看下打印的log,可以看出第二次请求的开始,很有可能超出20s的时间限制,导致axois提前结束。
// 超时设置20s
firts: 19.747s
// 中间可能有多少毫秒的操作
secend: 29.197ms
总结
既然是官方还未解决的问题,一方面我们需要根据场景适当地调整超时设置,另一方面也要采取重试进行兜底。