diff --git a/apps/desktop/electron/api-transport.test.ts b/apps/desktop/electron/api-transport.test.ts index 008c6b0dca..bc622b67c4 100644 --- a/apps/desktop/electron/api-transport.test.ts +++ b/apps/desktop/electron/api-transport.test.ts @@ -121,7 +121,9 @@ describe('withRetry', () => { () => { attempts += 1 - if (attempts < 3) {return Promise.reject(errWithCode('ECONNRESET'))} + if (attempts < 3) { + return Promise.reject(errWithCode('ECONNRESET')) + } return Promise.resolve('ok') }, @@ -141,7 +143,9 @@ describe('withRetry', () => { state.bodySent = true attempts += 1 - if (attempts < 2) {return Promise.reject(errWithCode('ECONNREFUSED'))} + if (attempts < 2) { + return Promise.reject(errWithCode('ECONNREFUSED')) + } return Promise.resolve(null) }, diff --git a/apps/desktop/electron/api-transport.ts b/apps/desktop/electron/api-transport.ts index 5a9d94190e..42afade9d1 100644 --- a/apps/desktop/electron/api-transport.ts +++ b/apps/desktop/electron/api-transport.ts @@ -62,7 +62,16 @@ function destroyKeepaliveAgents() { } // 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']) +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. @@ -75,9 +84,13 @@ function isIdempotentMethod(method) { } function isTransientTransportError(error) { - if (!error) {return false} + if (!error) { + return false + } - if (TRANSIENT_CODES.has(error.code)) {return true} + if (TRANSIENT_CODES.has(error.code)) { + return true + } const msg = String(error.message || '') return msg.includes('socket hang up') || msg.includes('read ECONNRESET') @@ -93,14 +106,22 @@ function isTransientTransportError(error) { * flushed, so a `false` here proves nothing went out. */ function shouldRetryRequest(error, method, requestState: any = {}) { - if (!isTransientTransportError(error)) {return false} + if (!isTransientTransportError(error)) { + return false + } - if (isIdempotentMethod(method)) {return true} + 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 (NEVER_SENT_CODES.has(error && error.code)) { + return true + } - if (requestState.bodySent === false) {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. @@ -118,7 +139,8 @@ function shouldRetryRequest(error, method, requestState: any = {}) { 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)))) + const delayFn = + options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000)))) let lastError diff --git a/apps/desktop/electron/gateway-file-download-transport.test.ts b/apps/desktop/electron/gateway-file-download-transport.test.ts index fcb32a3ea0..631d3aee21 100644 --- a/apps/desktop/electron/gateway-file-download-transport.test.ts +++ b/apps/desktop/electron/gateway-file-download-transport.test.ts @@ -54,4 +54,3 @@ test('finalizeGatewayDownload prompts a save dialog then streams the response', // HTTP errors carry their status so a 404 can trigger the fallback. assert.match(fn, /error\.statusCode = statusCode/) }) - diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e0ba0815bf..2103811bcb 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4864,106 +4864,110 @@ function fetchJson(url, token, options: any = {}) { // Retry policy lives in api-transport.ts: idempotent verbs retry on any // transient transport error; POST/PUT/DELETE only when the request provably // never reached the server (see shouldRetryRequest) — never double-submit. - return withRetry((requestState: any) => new Promise((resolve, reject) => { - const { body, contentType } = options.upload - ? multipartBody(options.upload) - : { - body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), - contentType: 'application/json' + return withRetry( + (requestState: any) => + new Promise((resolve, reject) => { + const { body, contentType } = options.upload + ? multipartBody(options.upload) + : { + body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), + contentType: 'application/json' + } + + const parsed = new URL(url) + const client = parsed.protocol === 'https:' ? https : http + const agent = jsonAgentFor(parsed.protocol) + const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + + return } - const parsed = new URL(url) - const client = parsed.protocol === 'https:' ? https : http - const agent = jsonAgentFor(parsed.protocol) - const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const req = client.request( + parsed, + { + agent, + method: options.method || 'GET', + headers: { + ...headersForRemoteRequest(url), + ...(options.headers || {}), + 'Content-Type': contentType, + 'X-Hermes-Session-Token': token, + // RFC 8252 native flow authenticates the gated gateway with a bearer + // token instead of the loopback session-token header. When + // ``options.bearer`` is set we send Authorization: Bearer ; + // the gateway's OAuth gate verifies it via the provider stack with + // no cookie involved. + ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), + ...(body ? { 'Content-Length': String(body.length) } : {}) + } + }, + res => { + const chunks = [] + res.on('error', reject) + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + if ((res.statusCode || 500) >= 400) { + reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) - return - } + return + } - const req = client.request( - parsed, - { - agent, - method: options.method || 'GET', - headers: { - ...headersForRemoteRequest(url), - ...(options.headers || {}), - 'Content-Type': contentType, - 'X-Hermes-Session-Token': token, - // RFC 8252 native flow authenticates the gated gateway with a bearer - // token instead of the loopback session-token header. When - // ``options.bearer`` is set we send Authorization: Bearer ; - // the gateway's OAuth gate verifies it via the provider stack with - // no cookie involved. - ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), - ...(body ? { 'Content-Length': String(body.length) } : {}) - } - }, - res => { - const chunks = [] - res.on('error', reject) - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8') + if (!text) { + resolve(null) - if ((res.statusCode || 500) >= 400) { - reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return + } - return + // A 2xx response whose body is HTML means the request fell through + // to the SPA index.html (e.g. an unregistered /api path). JSON.parse + // would throw an opaque `Unexpected token '<'` here, so surface a + // clear diagnostic with the offending URL instead. + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) + const contentType = String(res.headers['content-type'] || '') + + if (looksHtml || contentType.includes('text/html')) { + reject( + new Error( + `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + + 'The endpoint is likely missing on the Hermes backend.' + ) + ) + + return + } + + try { + resolve(JSON.parse(text)) + } catch { + reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) + } + }) } + ) - if (!text) { - resolve(null) - - return - } - - // A 2xx response whose body is HTML means the request fell through - // to the SPA index.html (e.g. an unregistered /api path). JSON.parse - // would throw an opaque `Unexpected token '<'` here, so surface a - // clear diagnostic with the offending URL instead. - const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) - const contentType = String(res.headers['content-type'] || '') - - if (looksHtml || contentType.includes('text/html')) { - reject( - new Error( - `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + - 'The endpoint is likely missing on the Hermes backend.' - ) - ) - - return - } - - try { - resolve(JSON.parse(text)) - } catch { - reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) - } + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - } - ) - req.on('error', reject) - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) - }) + // From here the request goes on the wire: a later transport error can no + // longer prove the server didn't process it, so non-idempotent verbs must + // not be retried past this point. + requestState.bodySent = true - // From here the request goes on the wire: a later transport error can no - // longer prove the server didn't process it, so non-idempotent verbs must - // not be retried past this point. - requestState.bodySent = true + if (body) { + req.write(body) + } - if (body) { - req.write(body) - } - - req.end() - }), { method: options.method || 'GET' }) + req.end() + }), + { method: options.method || 'GET' } + ) } // Token-auth download that streams the response body straight to a @@ -5031,95 +5035,99 @@ function fetchPublicJson(url, options: any = {}) { // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before // any credentials exist, and any time we must not leak a token to an // endpoint that doesn't need one. - return withRetry((requestState: any) => new Promise((resolve, reject) => { - const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) - let parsed + return withRetry( + (requestState: any) => + new Promise((resolve, reject) => { + const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) + let parsed - try { - parsed = new URL(url) - } catch (error) { - reject(new Error(`Invalid URL: ${error.message}`)) + try { + parsed = new URL(url) + } catch (error) { + reject(new Error(`Invalid URL: ${error.message}`)) - return - } - - const client = parsed.protocol === 'https:' ? https : http - const agent = jsonAgentFor(parsed.protocol) - const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) - - return - } - - const req = client.request( - parsed, - { - agent, - method: options.method || 'GET', - headers: { - ...headersForRemoteRequest(url), - ...(options.headers || {}), - 'Content-Type': 'application/json', - ...(body ? { 'Content-Length': String(body.length) } : {}) + return } - }, - res => { - const chunks = [] - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8') - if ((res.statusCode || 500) >= 400) { - reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + const client = parsed.protocol === 'https:' ? https : http + const agent = jsonAgentFor(parsed.protocol) + const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - return + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + + return + } + + const req = client.request( + parsed, + { + agent, + method: options.method || 'GET', + headers: { + ...headersForRemoteRequest(url), + ...(options.headers || {}), + 'Content-Type': 'application/json', + ...(body ? { 'Content-Length': String(body.length) } : {}) + } + }, + res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + + if ((res.statusCode || 500) >= 400) { + reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + + return + } + + if (!text) { + resolve(null) + + return + } + + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) + const contentType = String(res.headers['content-type'] || '') + + if (looksHtml || contentType.includes('text/html')) { + reject( + new Error( + `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + + 'The endpoint is likely missing on the Hermes backend.' + ) + ) + + return + } + + try { + resolve(JSON.parse(text)) + } catch { + reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) + } + }) } + ) - if (!text) { - resolve(null) - - return - } - - const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) - const contentType = String(res.headers['content-type'] || '') - - if (looksHtml || contentType.includes('text/html')) { - reject( - new Error( - `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + - 'The endpoint is likely missing on the Hermes backend.' - ) - ) - - return - } - - try { - resolve(JSON.parse(text)) - } catch { - reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) - } + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - } - ) - req.on('error', reject) - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) - }) + // Past this point the request is on the wire — see fetchJson. + requestState.bodySent = true - // Past this point the request is on the wire — see fetchJson. - requestState.bodySent = true + if (body) { + req.write(body) + } - if (body) { - req.write(body) - } - - req.end() - }), { method: options.method || 'GET' }) + req.end() + }), + { method: options.method || 'GET' } + ) } function mimeTypeForPath(filePath) {