Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared HTTP transport policy for the Electron main process's Hermes REST
|
||||
* helpers (fetchJson / fetchPublicJson / downloadViaTokenToFile).
|
||||
*
|
||||
* Two concerns live here so they can be unit-tested without Electron:
|
||||
*
|
||||
* 1. Connection-pooled keep-alive agents. Opening a fresh TCP socket per call
|
||||
* is what produced the burst-traffic ECONNRESET storms (#92976): the
|
||||
* backend closes idle keep-alive sockets and the next write on a reused
|
||||
* raw socket dies with 'socket hang up'. JSON calls and streaming
|
||||
* downloads get SEPARATE pools so a handful of long-lived download
|
||||
* streams can never starve the small, latency-sensitive JSON calls out of
|
||||
* the socket pool.
|
||||
*
|
||||
* 2. A retry policy that is safe for non-idempotent verbs. A transient
|
||||
* transport error does NOT mean the server didn't process the request —
|
||||
* an ECONNRESET can arrive after the backend already handled a POST
|
||||
* (created the session, submitted the prompt) and merely lost the socket
|
||||
* before the response was read. Blindly retrying every verb double-submits.
|
||||
*
|
||||
* The rule implemented by shouldRetryRequest():
|
||||
* - Idempotent verbs (GET / HEAD / OPTIONS) retry on any transient
|
||||
* transport error — replaying them is harmless by definition.
|
||||
* - Non-idempotent verbs (POST / PUT / PATCH / DELETE) retry ONLY when
|
||||
* the request provably never reached the server:
|
||||
* a) connection-establishment failures (ECONNREFUSED, ENOTFOUND,
|
||||
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH) — no connection means no
|
||||
* request; or
|
||||
* b) a transient error thrown before we started flushing the
|
||||
* request (requestState.bodySent === false).
|
||||
* Anything ambiguous — ECONNRESET / EPIPE / 'socket hang up' after
|
||||
* the body went out — is NOT retried; the error surfaces to the
|
||||
* caller. When in doubt, don't retry a non-idempotent request.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
// JSON pool: many small concurrent calls (session lists, config, prompts).
|
||||
const HTTP_JSON_AGENT = new http.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
const HTTPS_JSON_AGENT = new https.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
|
||||
// Download pool: few long-lived streaming bodies. Isolated from the JSON pool
|
||||
// so saturating it with large file downloads can't block interactive calls.
|
||||
const HTTP_DOWNLOAD_AGENT = new http.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
const HTTPS_DOWNLOAD_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
|
||||
function jsonAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_JSON_AGENT : HTTP_JSON_AGENT
|
||||
}
|
||||
|
||||
function downloadAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_DOWNLOAD_AGENT : HTTP_DOWNLOAD_AGENT
|
||||
}
|
||||
|
||||
// Close pooled sockets so lingering keep-alive connections can't hold the
|
||||
// process open (or leak FDs) across quit. Wired to app 'will-quit' in main.ts.
|
||||
function destroyKeepaliveAgents() {
|
||||
for (const agent of [HTTP_JSON_AGENT, HTTPS_JSON_AGENT, HTTP_DOWNLOAD_AGENT, HTTPS_DOWNLOAD_AGENT]) {
|
||||
agent.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Transient transport errors: retry MAY be safe (subject to verb gating).
|
||||
const TRANSIENT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH'
|
||||
])
|
||||
|
||||
// Errors that prove the request never reached the server: the TCP connection
|
||||
// (or name resolution) failed outright, so nothing was submitted.
|
||||
const NEVER_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH'])
|
||||
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
function isIdempotentMethod(method) {
|
||||
return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase())
|
||||
}
|
||||
|
||||
function isTransientTransportError(error) {
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (TRANSIENT_CODES.has(error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const msg = String(error.message || '')
|
||||
|
||||
return msg.includes('socket hang up') || msg.includes('read ECONNRESET')
|
||||
}
|
||||
|
||||
/**
|
||||
* The verb-gated retry decision.
|
||||
*
|
||||
* @param error the transport error from the failed attempt
|
||||
* @param method HTTP verb of the request ('GET', 'POST', ...)
|
||||
* @param requestState per-attempt state; requestState.bodySent is set true by
|
||||
* the caller just BEFORE the first byte of the request is
|
||||
* flushed, so a `false` here proves nothing went out.
|
||||
*/
|
||||
function shouldRetryRequest(error, method, requestState: any = {}) {
|
||||
if (!isTransientTransportError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isIdempotentMethod(method)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Non-idempotent: only when the request provably never reached the server.
|
||||
if (NEVER_SENT_CODES.has(error && error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestState.bodySent === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ambiguous (reset/hang-up after the body was flushed): the server may have
|
||||
// processed it. Surface the error rather than risk a double submit.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `makeAttempt` with bounded retries under the policy above.
|
||||
*
|
||||
* `makeAttempt(requestState)` must return a Promise and should set
|
||||
* `requestState.bodySent = true` immediately before flushing the request
|
||||
* (before the first req.write()/req.end()). Each attempt gets a fresh state
|
||||
* object initialized to { bodySent: false }.
|
||||
*/
|
||||
async function withRetry(makeAttempt, options: any = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2
|
||||
|
||||
const delayFn =
|
||||
options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000))))
|
||||
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const requestState = { bodySent: false }
|
||||
|
||||
try {
|
||||
return await makeAttempt(requestState)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt < maxRetries && shouldRetryRequest(error, method, requestState)) {
|
||||
await delayFn(attempt)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
}
|
||||
Reference in New Issue
Block a user