fetchJson/fetchPublicJson only reached the redirect diagnostic when the 3xx carried an HTML body or text/html content-type; an empty-body 302/307 (the common reverse-proxy / forward-auth shape) still resolved null through the earlier empty-body check. http.request never follows redirects, so classify on status first and reject every 3xx with the redirect error regardless of body. Pass res.headers.location through so the message says where the request was sent, and stop blaming credentials when the Location differs from the requested URL only by scheme or trailing slash -- that is a saved-URL mismatch, not an authentication proxy. Drop the 404 mention from the docstring/test: both callers reject >= 400 before this branch, so only the 2xx leg carries the endpoint-missing capability wording. Part of #112072
260 lines
9.4 KiB
TypeScript
260 lines
9.4 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* The one error shape the REST helpers throw for an HTTP >= 400 response.
|
|
*
|
|
* `statusCode` is the structured contract every downstream classifier reads —
|
|
* isGatewayAuthRejection (401/403 → reauth, never retried), isServerSideHttpError
|
|
* (502/503/504 → Cloud-down), ensureNativeAccessToken's dead-refresh-token
|
|
* check — and the "<status>: <body>" message keeps the legacy prefix readers
|
|
* working. fetchJson used to build a bare Error here, so a native-bearer 401
|
|
* reached the boot path as an anonymous transport failure: it was retried,
|
|
* then classified as transient, and the renderer's boot-retry loop flickered
|
|
* the Sign in overlay away (#95701). Shared by fetchJson, fetchPublicJson and
|
|
* the OAuth-session fetch so the three paths cannot drift apart again.
|
|
*/
|
|
function httpStatusError(statusCode, text, statusMessage?) {
|
|
const status = Number.isInteger(statusCode) && statusCode > 0 ? statusCode : 500
|
|
const detail = String(text || statusMessage || '')
|
|
const error: any = new Error(`${status}: ${detail}`)
|
|
error.statusCode = status
|
|
|
|
return error
|
|
}
|
|
|
|
/** Read side of httpStatusError: the HTTP status an error carries, NaN when it carries none. */
|
|
function readStatusCode(error: unknown): number {
|
|
return Number(error && typeof error === 'object' ? (error as { statusCode?: unknown }).statusCode : NaN)
|
|
}
|
|
|
|
/**
|
|
* Error for a JSON endpoint that did not answer JSON. A 2xx HTML body is the
|
|
* SPA index.html for an unregistered /api path, and downstream capability
|
|
* probes (isMissingHealthEndpointError, gateway-rpc) key on the "endpoint is
|
|
* likely missing" wording. A 3xx (callers never follow redirects, whatever
|
|
* the body) is the request being bounced elsewhere: an access proxy sending
|
|
* it to its login page, or a scheme / trailing-slash redirect when the saved
|
|
* URL is off by that much — so it must neither carry that wording nor blame
|
|
* the backend, and it names the Location when the server sent one.
|
|
*/
|
|
function htmlResponseError(url: string, statusCode: unknown, location?: unknown) {
|
|
const status = Number(statusCode)
|
|
|
|
if (status >= 300 && status < 400) {
|
|
const target = typeof location === 'string' && location.trim() ? location.trim() : null
|
|
|
|
const hint = isSchemeOrSlashRedirect(url, target)
|
|
? 'The saved gateway URL differs from the server by scheme or trailing slash; update it to match.'
|
|
: 'This is usually an authentication proxy in front of the gateway; check the saved token and extra gateway headers.'
|
|
|
|
return new Error(
|
|
`Expected JSON from ${url} but the request was redirected (status ${statusCode})${target ? ` to ${target}` : ''}. ${hint}`
|
|
)
|
|
}
|
|
|
|
return new Error(
|
|
`Expected JSON from ${url} but got HTML (status ${statusCode}). The endpoint is likely missing on the Hermes backend.`
|
|
)
|
|
}
|
|
|
|
function isSchemeOrSlashRedirect(requestUrl: string, location: null | string): boolean {
|
|
if (!location) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const from = new URL(requestUrl)
|
|
const to = new URL(location, requestUrl)
|
|
const strip = (value: string) => value.replace(/\/+$/, '')
|
|
|
|
return (
|
|
from.host === to.host &&
|
|
from.search === to.search &&
|
|
(from.protocol !== to.protocol || from.pathname !== to.pathname) &&
|
|
strip(from.pathname) === strip(to.pathname)
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export {
|
|
destroyKeepaliveAgents,
|
|
downloadAgentFor,
|
|
htmlResponseError,
|
|
httpStatusError,
|
|
isIdempotentMethod,
|
|
isTransientTransportError,
|
|
jsonAgentFor,
|
|
readStatusCode,
|
|
shouldRetryRequest,
|
|
withRetry
|
|
}
|