qgOutboundAxios.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * 与 lepaoSchoolHttp 一致的青果 HTTP 出站:HttpsProxyAgent + 先代理(短超时)再可回退直连。
  3. * 供电费、选课书单等非 Worker 场景的对外 GET/POST 使用。
  4. */
  5. const axios = require('axios')
  6. const HttpsProxyAgent = require('https-proxy-agent')
  7. const QgProxyManager = require('./QgProxyManager')
  8. const PROXY_FIRST_TIMEOUT_MS = 20000
  9. function sleep(ms) {
  10. return new Promise(r => setTimeout(r, ms))
  11. }
  12. async function getOutboundWithBackoff(qgOpts, rounds = 2) {
  13. let lastErr
  14. for (let i = 0; i < rounds; i++) {
  15. try {
  16. if (i > 0) await sleep(380 * i * i)
  17. return await QgProxyManager.getOutboundAxiosFragment(qgOpts)
  18. } catch (e) {
  19. lastErr = e
  20. }
  21. }
  22. throw lastErr
  23. }
  24. function buildAxiosOutboundConfig(fragment) {
  25. if (!fragment || fragment.proxy === false || !fragment.proxy) {
  26. return { proxy: false }
  27. }
  28. const { host, port, auth } = fragment.proxy
  29. let userPart = ''
  30. if (auth && String(auth.username || '').length > 0) {
  31. const u = encodeURIComponent(auth.username)
  32. const p = encodeURIComponent(auth.password != null ? String(auth.password) : '')
  33. userPart = `${u}:${p}@`
  34. }
  35. const proxyUrl = `http://${userPart}${host}:${port}`
  36. const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
  37. return {
  38. proxy: false,
  39. httpsAgent: new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
  40. }
  41. }
  42. function debugProxyEnabled() {
  43. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  44. }
  45. function debugProxyAxiosFragment() {
  46. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  47. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  48. return {
  49. proxy: {
  50. host,
  51. port,
  52. protocol: 'http'
  53. }
  54. }
  55. }
  56. function briefUrlPath(fullUrl) {
  57. try {
  58. const u = new URL(fullUrl)
  59. return `${u.pathname}${u.search}`
  60. } catch {
  61. return fullUrl
  62. }
  63. }
  64. function isQgProxyEligibleFailure(err) {
  65. if (!err) return false
  66. const status = err.response?.status
  67. if (status === 407 || status === 408 || status === 500) return true
  68. if (status === 502 || status === 503 || status === 504) return true
  69. if (
  70. err.code &&
  71. ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS'].includes(
  72. err.code
  73. )
  74. ) {
  75. return true
  76. }
  77. if (err.isAxiosError && !err.response) return true
  78. const msg = (err.message || '').toLowerCase()
  79. if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true
  80. return false
  81. }
  82. function summarizeAxiosError(err) {
  83. if (!err) return {}
  84. return {
  85. message: err.message,
  86. code: err.code,
  87. status: err.response?.status,
  88. isAxiosError: err.isAxiosError
  89. }
  90. }
  91. function isProxyTlsHandshakeReset(err) {
  92. if (!err) return false
  93. const code = err.code
  94. if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
  95. const msg = String(err.message || '')
  96. return /tls|secure\s+tls|handshake/i.test(msg)
  97. }
  98. function logLabel(traceId, mqTaskId) {
  99. let s = ''
  100. if (traceId) s += `[${traceId}] `
  101. s += '[qgOutboundAxios]'
  102. if (mqTaskId) s += ` [${mqTaskId}]`
  103. return s
  104. }
  105. /**
  106. * @param {{
  107. * method?: 'get'|'post'
  108. * url: string
  109. * data?: any
  110. * headers?: object
  111. * timeout?: number
  112. * outboundMode?: 'auto'|'direct'|'proxy'
  113. * logger?: { info?: Function, warn?: Function, error?: Function }
  114. * traceId?: string|null
  115. * mqTaskId?: string|null
  116. * scene?: string
  117. * validateStatus?: (status:number)=>boolean
  118. * responseType?: string
  119. * transformResponse?: Function[]
  120. * }} opts
  121. */
  122. async function axiosWithQgOutbound(opts) {
  123. const {
  124. method = 'get',
  125. url,
  126. data,
  127. headers = {},
  128. timeout = 15000,
  129. outboundMode = 'auto',
  130. logger = null,
  131. traceId = null,
  132. mqTaskId = null,
  133. scene = 'outbound',
  134. validateStatus,
  135. responseType,
  136. transformResponse
  137. } = opts
  138. const m = String(method).toLowerCase()
  139. const lbl = () => logLabel(traceId, mqTaskId)
  140. const baseAxiosOpts = {
  141. headers,
  142. timeout,
  143. proxy: false,
  144. ...(validateStatus ? { validateStatus } : {}),
  145. ...(responseType ? { responseType } : {}),
  146. ...(transformResponse ? { transformResponse } : {})
  147. }
  148. const exec = (fragment, requestTimeout) => {
  149. const outbound = buildAxiosOutboundConfig(fragment)
  150. const merged = { ...baseAxiosOpts, ...outbound, timeout: requestTimeout }
  151. if (m === 'post') {
  152. return axios.post(url, data, merged)
  153. }
  154. return axios.get(url, merged)
  155. }
  156. if (outboundMode === 'direct') {
  157. logger?.info?.(`${lbl()} (${scene}) 强制直连 ${m.toUpperCase()} ${briefUrlPath(url)}`)
  158. return exec({ proxy: false }, timeout)
  159. }
  160. if (debugProxyEnabled()) {
  161. const dbg = debugProxyAxiosFragment()
  162. logger?.info?.(`${lbl()} (${scene}) Charles 调试代理 LEPAO_DEBUG_PROXY ${briefUrlPath(url)}`)
  163. return exec(dbg, timeout)
  164. }
  165. const qgOn = await QgProxyManager.isOutboundProxyEnabled()
  166. if (!qgOn) {
  167. logger?.info?.(`${lbl()} (${scene}) 青果未启用 直连 ${briefUrlPath(url)}`)
  168. return exec({ proxy: false }, timeout)
  169. }
  170. let frag
  171. try {
  172. frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
  173. } catch (e0) {
  174. if (outboundMode === 'proxy') {
  175. const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
  176. err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
  177. err.retryable = true
  178. throw err
  179. }
  180. logger?.error?.(`${lbl()} (${scene}) 青果提取失败改直连: ${e0.message || e0}`)
  181. await QgProxyManager.recordFallbackDirect({
  182. reason: 'qg_extract_error',
  183. path: briefUrlPath(url),
  184. scene,
  185. mq_task_id: mqTaskId,
  186. trace_id: traceId,
  187. ...summarizeAxiosError(e0)
  188. })
  189. return exec({ proxy: false }, timeout)
  190. }
  191. if (frag.proxy === false) {
  192. try {
  193. await sleep(400)
  194. frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
  195. } catch {
  196. /* keep */
  197. }
  198. }
  199. if (frag.proxy === false) {
  200. if (outboundMode === 'proxy') {
  201. const err = new Error('代理模式无可用节点')
  202. err.code = 'PROXY_REQUIRED_NO_NODE'
  203. err.retryable = true
  204. throw err
  205. }
  206. logger?.warn?.(`${lbl()} (${scene}) 无可用节点 直连 ${briefUrlPath(url)}`)
  207. await QgProxyManager.recordFallbackDirect({
  208. reason: 'no_proxy_available',
  209. path: briefUrlPath(url),
  210. scene,
  211. mq_task_id: mqTaskId,
  212. trace_id: traceId
  213. })
  214. return exec({ proxy: false }, timeout)
  215. }
  216. logger?.info?.(`${lbl()} (${scene}) 经代理 ${m.toUpperCase()} ${briefUrlPath(url)}`)
  217. try {
  218. return await exec(frag, PROXY_FIRST_TIMEOUT_MS)
  219. } catch (e1) {
  220. if (outboundMode === 'proxy') {
  221. const err = new Error(`代理模式请求失败: ${e1.message || e1}`)
  222. err.code = 'PROXY_REQUIRED_REQUEST_FAILED'
  223. err.retryable = true
  224. throw err
  225. }
  226. if (!isQgProxyEligibleFailure(e1)) throw e1
  227. logger?.warn?.(
  228. `${lbl()} (${scene}) 代理失败回退直连 err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
  229. )
  230. if (isProxyTlsHandshakeReset(e1)) {
  231. await QgProxyManager.recordFallbackDirect({
  232. reason: 'tls_prefinish_reset_direct',
  233. path: briefUrlPath(url),
  234. scene,
  235. mq_task_id: mqTaskId,
  236. trace_id: traceId,
  237. ...summarizeAxiosError(e1)
  238. })
  239. return exec({ proxy: false }, timeout)
  240. }
  241. await QgProxyManager.recordFallbackDirect({
  242. reason: 'proxy_post_failed_then_direct',
  243. path: briefUrlPath(url),
  244. scene,
  245. mq_task_id: mqTaskId,
  246. trace_id: traceId,
  247. ...summarizeAxiosError(e1)
  248. })
  249. return exec({ proxy: false }, timeout)
  250. }
  251. }
  252. module.exports = {
  253. axiosWithQgOutbound,
  254. buildAxiosOutboundConfig,
  255. getOutboundWithBackoff,
  256. isQgProxyEligibleFailure,
  257. isProxyTlsHandshakeReset,
  258. summarizeAxiosError,
  259. debugProxyEnabled,
  260. debugProxyAxiosFragment,
  261. sleep,
  262. PROXY_FIRST_TIMEOUT_MS
  263. }