qgOutboundAxios.js 7.9 KB

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