使用https.request发起POST请求返回408错误的技术求助
Express反向代理POST请求408错误排查与修复
背景
基于Express开发了一个简易HTTP请求反向代理,用于将本地HTTP/HTTPS请求转发至内部部署系统(本地通过VPN连接)。代理仅将Accept-encoding头设为utf-8,其余头部均原样转发。
当前GET和HEAD请求可成功访问内部系统,但POST请求返回408错误。直接使用Postman发起相同POST请求可得到201状态码,通过代理用Postman发起该请求也能成功,因此问题出在自定义的request函数实现上。
代码功能概述
- 代理无需使用解码包处理请求体,因已设置
Accept-encoding为utf-8 - 当
do_retry标志启用时,代理的backoff函数为简单指数退避函数(测试场景未启用)
现有request函数实现
/** * 发送HTTP/HTTPS请求并返回Promise * @param {{}} config 请求配置对象 * @param {Buffer|String|{}} data 要发送的请求体数据 * @param {Number} retries 失败时的重试次数,默认使用request_retries变量 * @returns {Promise.<{status: Number|undefined,status_message:String|undefined,headers:http.IncomingHttpHeaders,body:any}>} 成功时返回响应对象,失败时抛出错误 */ const request = (config, data = null, retries = request_retries) => { // eslint-disable-next-line no-unused-vars return new Promise((resolve, reject) => { try { // 获取对应协议的请求模块 const _agent = config.protocol === "https" ? https : http; delete config.protocol; let _data = []; for (let i = 0; i < retries; i++) { const req = _agent.request(config, (res) => { // 收集响应数据 res.on("data", (chunk) => { _data.push(chunk); }); // 处理响应结束事件 res.on("end", async () => { let _body; try { if (res.headers["content-type"]?.includes("application/json")) { // 移除JSON语法错误相关的非法空白字符(注释掉的代码) // _data = _data.map((chunk) => chunk.toString().replace(/[\n\r\s\t]+/g, " ")); _body = JSON.parse(Buffer.concat(_data).toString("utf-8")); } // 解析text/html类型的响应 else if (res.headers["content-type"]?.includes("text/html")) { _body = Buffer.concat(_data).toString("utf-8"); } else { // 根据响应头的编码格式解码,默认用utf-8 _body = Buffer.concat(_data).toString(res.headers["content-encoding"] ?? "utf-8"); } } catch (err) { _body = Buffer.concat(_data).toString(); } const response = { status: res.statusCode, status_message: res.statusMessage, headers: res.headers, body: _body, }; // 判断是否解析响应 if (res.statusCode >= 200 && res.statusCode < 300) { resolve(response); } else if (do_retry) { console.warn(`[${new Date().toISOString()}][PROXY] ${config.method} 请求到 ${config.hostname ?? config.host + config.path} 失败,状态码 ${res.statusCode}。\n正在重试...`); // 调用退避函数后重试 await backoff(i); } else if (i === retries - 1) { resolve(response); } else { resolve(response); } }); // 超时处理 res.on("timeout", () => { reject(new Error(`请求到 ${config.hostname ?? config.host + config.path} 超时。`)); }); res.on("error", (err) => { reject(err); }); }); // 根据数据类型发送请求体 if (data && data instanceof Buffer) { req.write(data); } else if (data && typeof data === "string") { req.write(data, "utf-8"); } else if (data && typeof data === "object") { req.write(JSON.stringify(data), "utf-8"); } // 结束请求 req.end(); } } catch (err) { reject(err); } }); };
问题根源分析
错误的重试触发逻辑
当前代码用for循环直接发起retries次请求,不管前一次请求是否失败,会导致同时发送多个重复请求,且所有请求的响应数据会被累积到同一个_data数组中,造成响应解析混乱。缺失Content-Length头
POST请求必须携带Content-Length头告诉服务器请求体的大小,否则服务器会一直等待数据直到超时,返回408错误。当前代码未设置该头,而Postman会自动添加,因此直接用Postman请求能成功。异步回调中的await无效
res.on("end")是普通异步回调,里面的await backoff(i)无法阻塞代码执行,且Promise会在第一次请求结束时就触发resolve,导致重试逻辑根本无法正确执行。
修复后的request函数实现
/** * 发送HTTP/HTTPS请求并返回Promise * @param {{}} config 请求配置对象 * @param {Buffer|String|{}} data 要发送的请求体数据 * @param {Number} retries 失败时的重试次数,默认使用request_retries变量 * @returns {Promise.<{status: Number|undefined,status_message:String|undefined,headers:http.IncomingHttpHeaders,body:any}>} 成功时返回响应对象,失败时抛出错误 */ const request = (config, data = null, retries = request_retries) => { return new Promise((resolve, reject) => { // 定义递归重试函数 const attemptRequest = (currentRetry) => { try { const _agent = config.protocol === "https" ? https : http; // 复制配置对象,避免修改原配置 const requestConfig = { ...config }; delete requestConfig.protocol; let requestData = null; // 统一处理请求体为Buffer,并设置Content-Length if (data) { if (data instanceof Buffer) { requestData = data; } else if (typeof data === "string") { requestData = Buffer.from(data, "utf-8"); } else if (typeof data === "object") { requestData = Buffer.from(JSON.stringify(data), "utf-8"); } // 添加或覆盖Content-Length头 requestConfig.headers = { ...requestConfig.headers, "Content-Length": requestData.length }; } const req = _agent.request(requestConfig, (res) => { // 每个请求单独维护响应数据数组,避免污染 const _data = []; res.on("data", (chunk) => { _data.push(chunk); }); res.on("end", () => { let _body; try { if (res.headers["content-type"]?.includes("application/json")) { _body = JSON.parse(Buffer.concat(_data).toString("utf-8")); } else if (res.headers["content-type"]?.includes("text/html")) { _body = Buffer.concat(_data).toString("utf-8"); } else { _body = Buffer.concat(_data).toString(res.headers["content-encoding"] ?? "utf-8"); } } catch (err) { _body = Buffer.concat(_data).toString(); } const response = { status: res.statusCode, status_message: res.statusMessage, headers: res.headers, body: _body }; // 处理响应状态码 if (res.statusCode >= 200 && res.statusCode < 300) { resolve(response); } else if (do_retry && currentRetry < retries - 1) { console.warn(`[${new Date().toISOString()}][PROXY] ${requestConfig.method} 请求到 ${requestConfig.hostname ?? requestConfig.host + requestConfig.path} 失败,状态码 ${res.statusCode}。\n正在重试...`); // 用定时器实现退避重试 setTimeout(() => { attemptRequest(currentRetry + 1); }, backoff(currentRetry)); } else { resolve(response); } }); // 超时处理 res.on("timeout", () => { if (do_retry && currentRetry < retries - 1) { console.warn(`[${new Date().toISOString()}][PROXY] 请求到 ${requestConfig.hostname ?? requestConfig.host + requestConfig.path} 超时。\n正在重试...`); setTimeout(() => { attemptRequest(currentRetry + 1); }, backoff(currentRetry)); } else { reject(new Error(`请求到 ${requestConfig.hostname ?? requestConfig.host + requestConfig.path} 超时。`)); } }); // 请求错误处理 res.on("error", (err) => { if (do_retry && currentRetry < retries - 1) { console.warn(`[${new Date().toISOString()}][PROXY] 请求到 ${requestConfig.hostname ?? requestConfig.host + requestConfig.path} 出错:${err.message}\n正在重试...`); setTimeout(() => { attemptRequest(currentRetry + 1); }, backoff(currentRetry)); } else { reject(err); } }); }); // 发送请求体并结束请求 if (requestData) { req.write(requestData); } req.end(); } catch (err) { // 初始化请求出错时的重试逻辑 if (do_retry && currentRetry < retries - 1) { console.warn(`[${new Date().toISOString()}][PROXY] 请求初始化出错:${err.message}\n正在重试...`); setTimeout(() => { attemptRequest(currentRetry + 1); }, backoff(currentRetry)); } else { reject(err); } } }; // 发起第一次请求 attemptRequest(0); }); };
修复说明
- 改用递归函数
attemptRequest处理重试,仅在当前请求失败且满足重试条件时才发起下一次请求 - 统一将请求体转换为Buffer,并计算设置
Content-Length头,解决POST请求的408超时问题 - 每个请求单独维护响应数据数组
_data,避免多次请求的响应数据混淆 - 将
await backoff替换为setTimeout,适配异步回调的执行逻辑,正确实现退避重试 - 修复重试次数判断逻辑,确保最多重试
retries次
内容的提问来源于stack exchange,提问作者Adithya Shetty
相关产品推荐
相关产品推荐

