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 }