From 47c3683f23fa6a57ad37e11461608c2ede2ecebc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:08:59 -0700 Subject: [PATCH] fix(desktop): stop calling a 3xx HTML reply a missing endpoint The JSON guard in fetchJson/fetchPublicJson blamed every HTML reply on a missing backend endpoint. A 3xx HTML body is an access proxy redirecting to its login page: the endpoint exists, the credentials never arrived. Besides misleading the user, the wording is the capability signal that isMissingHealthEndpointError and the renderer's gateway-rpc predicate key on, so an auth redirect was silently classified as "endpoint missing" and routed onto compatibility paths instead of surfacing as an error. Build the error in api-transport.ts (where the sibling httpStatusError lives) and pick the hint by status: 3xx names the redirect and points at the saved token/extra headers; everything else keeps the endpoint-missing wording the predicates rely on. Part of #112072 --- apps/desktop/electron/api-transport.test.ts | 14 ++++++++++++++ apps/desktop/electron/api-transport.ts | 20 ++++++++++++++++++++ apps/desktop/electron/main.ts | 15 +++------------ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/desktop/electron/api-transport.test.ts b/apps/desktop/electron/api-transport.test.ts index 787c98dba8..9190867bde 100644 --- a/apps/desktop/electron/api-transport.test.ts +++ b/apps/desktop/electron/api-transport.test.ts @@ -19,6 +19,7 @@ import { afterAll, describe, expect, it } from 'vitest' import { destroyKeepaliveAgents, downloadAgentFor, + htmlResponseError, httpStatusError, isIdempotentMethod, isTransientTransportError, @@ -404,3 +405,16 @@ describe('httpStatusError', () => { expect(httpStatusError(0, 'boom').statusCode).toBe(500) }) }) + +describe('htmlResponseError', () => { + it('names an auth redirect for 3xx HTML and keeps the endpoint-missing capability wording otherwise', () => { + const redirected = htmlResponseError('https://gateway.example.com/api/profiles', 302).message + + expect(redirected).toContain('status 302') + expect(redirected).toMatch(/redirected/) + expect(redirected).not.toContain('endpoint is likely missing') + expect(htmlResponseError('https://gateway.example.com/api/missing', 404).message).toContain( + 'endpoint is likely missing' + ) + }) +}) diff --git a/apps/desktop/electron/api-transport.ts b/apps/desktop/electron/api-transport.ts index 310c24608d..181b657e1a 100644 --- a/apps/desktop/electron/api-transport.ts +++ b/apps/desktop/electron/api-transport.ts @@ -194,9 +194,29 @@ function readStatusCode(error: unknown): number { return Number(error && typeof error === 'object' ? (error as { statusCode?: unknown }).statusCode : NaN) } +/** + * Error for a JSON endpoint that answered with HTML. A 2xx/404 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 is an access proxy bouncing the request to + * its login page instead — the endpoint exists, the credentials never + * arrived — so it must neither carry that wording nor blame the backend. + */ +function htmlResponseError(url: string, statusCode: unknown) { + const status = Number(statusCode) + + const hint = + status >= 300 && status < 400 + ? 'The request was redirected, usually by an authentication proxy in front of the gateway; check the saved token and extra gateway headers.' + : 'The endpoint is likely missing on the Hermes backend.' + + return new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}). ${hint}`) +} + export { destroyKeepaliveAgents, downloadAgentFor, + htmlResponseError, httpStatusError, isIdempotentMethod, isTransientTransportError, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b48f97d365..4801a770fc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -33,6 +33,7 @@ import { classifyActiveRuntime } from './active-runtime-state' import { destroyKeepaliveAgents, downloadAgentFor, + htmlResponseError, httpStatusError, jsonAgentFor, readStatusCode, @@ -5469,12 +5470,7 @@ function fetchJson(url, token, options: any = {}) { 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.' - ) - ) + reject(htmlResponseError(url, res.statusCode)) return } @@ -5631,12 +5627,7 @@ function fetchPublicJson(url, options: any = {}) { 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.' - ) - ) + reject(htmlResponseError(url, res.statusCode)) return }