diff --git a/.changes/portals-quiet-routine-request-outcomes.md b/.changes/portals-quiet-routine-request-outcomes.md new file mode 100644 index 000000000..1a421fea0 --- /dev/null +++ b/.changes/portals-quiet-routine-request-outcomes.md @@ -0,0 +1,10 @@ +--- +type: fix +area: portals +--- + +Cancelling a portal request or hitting an expired login no longer floods the +desktop log with stack traces: a cancelled Stalker or Xtream request stays +silent, an HTTP 401/403 is a single warning, and the message shown for a +refused request now names the real status instead of a generic connection +failure. diff --git a/apps/electron-backend-e2e/src/portal-request-logging.e2e.ts b/apps/electron-backend-e2e/src/portal-request-logging.e2e.ts new file mode 100644 index 000000000..006a4fdaa --- /dev/null +++ b/apps/electron-backend-e2e/src/portal-request-logging.e2e.ts @@ -0,0 +1,166 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + closeElectronApp, + expect, + launchElectronApp, + test, +} from './electron-test-fixtures'; + +/** + * Routine portal outcomes must not reach the main-process log as errors. + * + * Electron prints `Error occurred in handler for ''` — with a stack + * trace — for every `ipcMain.handle` promise that rejects. A request the + * renderer cancelled (navigation, a superseded or expired probe) and an HTTP + * 401/403 (the portal answered and refused) are expected, so both portal + * handlers resolve them as a `portalRequestFailure` envelope instead: the + * renderer's data service rethrows it (`portal-request-failure.util.ts`), the + * refusal is one credential-free warning, and the cancellation is silent. + */ + +const MAC_ADDRESS = '00:1A:79:00:00:31'; +const USERNAME = 'probe-user-31'; +const PASSWORD = 'probe-secret-31'; +const HANDLER_FAILURE = 'Error occurred in handler for'; +/** Electron colours its console output; the assertions read plain text. */ +const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +type Probe = { requestId: string; deadlineAt: number }; + +/** A portal that answers every request with 401, like an HTTP auth gate. */ +async function startRefusingPortal(): Promise<{ + origin: string; + close: () => Promise; +}> { + const server = createServer((_request, response) => { + response.writeHead(401, { + 'content-type': 'text/plain', + 'www-authenticate': 'Basic realm="portal"', + }); + response.end('Unauthorized'); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ), + }; +} + +test('@stalker @xtream @electron resolves cancelled and refused portal requests without logging handler errors', async ({ + dataDir, +}) => { + const portal = await startRefusingPortal(); + try { + await runAgainstRefusingPortal(dataDir, portal.origin); + } finally { + await portal.close(); + } +}); + +async function runAgainstRefusingPortal( + dataDir: string, + origin: string +): Promise { + // A developer shell with the IPC trace on would make the handlers log + // the cancellations on purpose; this test asserts the default silence. + const app = await launchElectronApp(dataDir, { + omitEnvKeys: ['IPTVNATOR_TRACE_IPC', 'IPTVNATOR_TRACE_STARTUP'], + }); + let diagnostics = ''; + const capture = (chunk: Buffer) => { + diagnostics += chunk.toString().replace(ANSI_SEQUENCE, ''); + }; + const electronProcess = app.electronApp.process(); + electronProcess.stdout?.on('data', capture); + electronProcess.stderr?.on('data', capture); + try { + const outcomes = await app.mainWindow.evaluate( + async ({ origin, macAddress, username, password }) => { + // A probe whose deadline already passed is aborted before the + // request leaves the main process: a deterministic cancel. + const expired = (requestId: string): Probe => ({ + requestId, + deadlineAt: Date.now(), + }); + const stalker = (probe?: Probe) => + window.electron.stalkerRequest({ + url: `${origin}/portal.php`, + macAddress, + params: { type: 'stb', action: 'handshake' }, + ...(probe ? { probe } : {}), + }); + const xtream = (probe?: Probe) => + window.electron.xtreamRequest({ + url: origin, + params: { + username, + password, + action: 'get_account_info', + }, + ...(probe ? { probe } : {}), + }); + return { + stalkerCancelled: await stalker(expired('stalker-cancel')), + xtreamCancelled: await xtream(expired('xtream-cancel')), + stalkerRefused: await stalker(), + xtreamRefused: await xtream(), + }; + }, + { + origin, + macAddress: MAC_ADDRESS, + username: USERNAME, + password: PASSWORD, + } + ); + + // Resolved, structured, and never shaped like an answer. + const cancelled = { portalRequestFailure: { kind: 'cancelled' } }; + const refused = { + portalRequestFailure: { + kind: 'http', + status: 401, + statusText: 'Unauthorized', + }, + }; + expect(outcomes.stalkerCancelled).toEqual(cancelled); + expect(outcomes.xtreamCancelled).toEqual(cancelled); + expect(outcomes.stalkerRefused).toEqual(refused); + expect(outcomes.xtreamRefused).toEqual(refused); + + // One warning per refusal; the cancellations stay silent; Electron + // never saw a rejected handler. + await expect + .poll( + () => + diagnostics.match(/\[(?:STALKER|XTREAM)_REQUEST\] Refused/g) + ?.length ?? 0 + ) + .toBe(2); + expect(diagnostics).toContain('[STALKER_REQUEST] Refused'); + expect(diagnostics).toContain('[XTREAM_REQUEST] Refused'); + expect(diagnostics).not.toContain(HANDLER_FAILURE); + expect(diagnostics).not.toMatch( + /\[(?:STALKER|XTREAM)_REQUEST\] (?:Failed|cancelled)/ + ); + + // Host and pathname only: no MAC, no credentials, no query string. + expect(diagnostics).toContain("pathname: '/portal.php'"); + expect(diagnostics).toContain("pathname: '/player_api.php'"); + expect(diagnostics).not.toContain(MAC_ADDRESS); + expect(diagnostics).not.toContain(USERNAME); + expect(diagnostics).not.toContain(PASSWORD); + expect(diagnostics).not.toMatch(/portal\.php\?|player_api\.php\?/); + } finally { + electronProcess.stdout?.off('data', capture); + electronProcess.stderr?.off('data', capture); + await closeElectronApp(app); + } +} diff --git a/apps/electron-backend/src/app/events/portal-request-outcome.spec.ts b/apps/electron-backend/src/app/events/portal-request-outcome.spec.ts new file mode 100644 index 000000000..640c5ac85 --- /dev/null +++ b/apps/electron-backend/src/app/events/portal-request-outcome.spec.ts @@ -0,0 +1,225 @@ +import { + classifyExpectedPortalFailure, + isCancelledPortalRequest, + logPortalRequestFailure, +} from './portal-request-outcome'; + +const REQUEST_URL = + 'http://portal.example.com:8080/stalker_portal/server/load.php?type=stb&action=handshake&mac=00%3A1A%3A79%3AAA%3ABB%3ACC&token=secret-token'; + +describe('classifyExpectedPortalFailure', () => { + it.each([ + [ + 'an axios CanceledError', + Object.assign(new Error('canceled'), { + code: 'ERR_CANCELED', + name: 'CanceledError', + }), + ], + [ + 'a bare ERR_CANCELED code', + Object.assign(new Error('cancelled'), { + code: 'ERR_CANCELED', + }), + ], + [ + 'an AbortError raised before axios', + Object.assign(new Error('aborted'), { name: 'AbortError' }), + ], + ])('reads %s as a cancellation', (_label, error) => { + expect(isCancelledPortalRequest(error)).toBe(true); + expect(classifyExpectedPortalFailure(error)).toEqual({ + kind: 'cancelled', + }); + }); + + it.each([401, 403])( + 'reads a thrown HTTP %s as a refusal, keeping the status text', + (status) => { + const error = Object.assign( + new Error(`HTTP Error ${status}: Refused`), + { status, statusText: 'Refused' } + ); + + expect(classifyExpectedPortalFailure(error)).toEqual({ + kind: 'http', + status, + statusText: 'Refused', + }); + expect( + classifyExpectedPortalFailure({ message: 'refused', status }) + ).toEqual({ kind: 'http', status }); + } + ); + + it.each([ + [ + 'a 404', + Object.assign(new Error('HTTP Error 404: Not Found'), { + status: 404, + }), + ], + [ + 'a 5xx axios error', + Object.assign(new Error('Bad Gateway'), { + code: 'ERR_BAD_RESPONSE', + response: { status: 502, statusText: 'Bad Gateway' }, + }), + ], + [ + 'a refused connection', + Object.assign(new Error('ECONNREFUSED'), { + code: 'ECONNREFUSED', + }), + ], + [ + 'an axios timeout', + Object.assign(new Error('timeout exceeded'), { + code: 'ECONNABORTED', + }), + ], + ['a parse error', new SyntaxError('Unexpected token')], + ['a non-object', 'boom'], + ])('keeps %s a real failure', (_label, error) => { + expect(classifyExpectedPortalFailure(error)).toBeNull(); + }); + + it('does not read a 401 inside a cancelled request as a refusal', () => { + const error = Object.assign(new Error('canceled'), { + code: 'ERR_CANCELED', + response: { status: 401 }, + }); + + expect(classifyExpectedPortalFailure(error)).toEqual({ + kind: 'cancelled', + }); + }); +}); + +describe('logPortalRequestFailure', () => { + const originalTraceIpc = process.env['IPTVNATOR_TRACE_IPC']; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + delete process.env['IPTVNATOR_TRACE_IPC']; + logSpy = jest.spyOn(console, 'log').mockImplementation(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + errorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + if (originalTraceIpc === undefined) { + delete process.env['IPTVNATOR_TRACE_IPC']; + } else { + process.env['IPTVNATOR_TRACE_IPC'] = originalTraceIpc; + } + }); + + const allOutput = () => + JSON.stringify([ + ...logSpy.mock.calls, + ...warnSpy.mock.calls, + ...errorSpy.mock.calls, + ]); + + it('is silent about a cancellation unless the IPC trace is on', () => { + const cancelled = Object.assign(new Error('canceled'), { + code: 'ERR_CANCELED', + }); + + logPortalRequestFailure( + 'STALKER_REQUEST', + { kind: 'cancelled' }, + cancelled, + REQUEST_URL, + 'handshake' + ); + expect(allOutput()).toBe('[]'); + + process.env['IPTVNATOR_TRACE_IPC'] = '1'; + logPortalRequestFailure( + 'STALKER_REQUEST', + { kind: 'cancelled' }, + cancelled, + REQUEST_URL, + 'handshake' + ); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0][0]).toContain('[STALKER_REQUEST]'); + expect(logSpy.mock.calls[0][0]).toContain('cancelled'); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('warns once about an HTTP refusal, with host and pathname only', () => { + logPortalRequestFailure( + 'XTREAM_REQUEST', + { kind: 'http', status: 401, statusText: 'Unauthorized' }, + Object.assign(new Error('HTTP Error 401: Unauthorized'), { + status: 401, + }), + REQUEST_URL, + 'handshake' + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toBe('[XTREAM_REQUEST] Refused'); + expect(warnSpy.mock.calls[0][1]).toMatchObject({ + action: 'handshake', + host: 'portal.example.com:8080', + pathname: '/stalker_portal/server/load.php', + status: 401, + }); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('keeps a real failure at error level', () => { + logPortalRequestFailure( + 'STALKER_REQUEST', + null, + Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + }), + REQUEST_URL, + 'handshake' + ); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toBe('[STALKER_REQUEST] Failed'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['cancelled', { kind: 'cancelled' } as const], + ['http', { kind: 'http', status: 403 } as const], + ['failed', null], + ])( + 'never writes the MAC, token or query string (%s)', + (_label, expected) => { + process.env['IPTVNATOR_TRACE_IPC'] = '1'; + logPortalRequestFailure( + 'STALKER_REQUEST', + expected, + Object.assign(new Error('HTTP Error 403: Forbidden'), { + status: 403, + config: { url: REQUEST_URL }, + }), + REQUEST_URL, + 'handshake' + ); + + const output = allOutput(); + expect(output).not.toBe('[]'); + expect(output).not.toContain('00:1A:79'); + expect(output).not.toContain('00%3A1A'); + expect(output).not.toContain('secret-token'); + expect(output).not.toContain('load.php?'); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/portal-request-outcome.ts b/apps/electron-backend/src/app/events/portal-request-outcome.ts new file mode 100644 index 000000000..de06be72d --- /dev/null +++ b/apps/electron-backend/src/app/events/portal-request-outcome.ts @@ -0,0 +1,128 @@ +import { + isExpectedPortalHttpStatus, + PortalRequestFailure, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; +import { isRendererApiTraceEnabled, trace } from '../services/debug-trace'; +import { formatPortalRequestError } from './portal-request-error.util'; + +/** + * Decides, once for both portal IPC handlers, whether a failed request is a + * routine outcome the renderer asked for or can handle (see + * `portal-request-failure.util.ts` in `shared/interfaces`) and logs it at + * the matching level. + * + * Routine outcomes are resolved by the handler as a structured envelope, so + * Electron does not print `Error occurred in handler for ''` with a + * stack trace for every navigation away from a portal or every expired + * credential. Real failures — network errors, 5xx, parse errors, every other + * status — keep rejecting and keep their error log. + */ + +/** + * Whether the transport gave up because the request's abort signal fired. + * axios rejects with `CanceledError` (`code: 'ERR_CANCELED'`); the name check + * covers an abort raised before axios was reached. + */ +export function isCancelledPortalRequest(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const { code, name } = error as { code?: unknown; name?: unknown }; + return ( + code === 'ERR_CANCELED' || + name === 'CanceledError' || + name === 'AbortError' + ); +} + +/** + * HTTP status of a failure that is a portal answer. The handlers throw the + * `validateStatus`-accepted 4xx as an error carrying `status`; a 5xx arrives + * as an axios error with a `response`. + */ +function readHttpStatus( + error: unknown +): { status: number; statusText?: string } | null { + if (!error || typeof error !== 'object') { + return null; + } + + const own = error as { + status?: unknown; + statusText?: unknown; + response?: { status?: unknown; statusText?: unknown }; + }; + const source = typeof own.status === 'number' ? own : own.response; + if (!source || typeof source.status !== 'number') { + return null; + } + + return { + status: source.status, + statusText: + typeof source.statusText === 'string' + ? source.statusText + : undefined, + }; +} + +/** + * The expected failure a request ended in, or null for a real failure. + */ +export function classifyExpectedPortalFailure( + error: unknown +): PortalRequestFailure | null { + if (isCancelledPortalRequest(error)) { + return { kind: 'cancelled' }; + } + + const http = readHttpStatus(error); + if (http && isExpectedPortalHttpStatus(http.status)) { + return http.statusText + ? { kind: 'http', status: http.status, statusText: http.statusText } + : { kind: 'http', status: http.status }; + } + + return null; +} + +/** + * One line per failed request, at the level its outcome deserves. + * + * - cancelled: nothing, unless `IPTVNATOR_TRACE_IPC` (or the startup trace) + * is on — the renderer asked for it; + * - HTTP 401/403: one `console.warn`, so an expired or wrong credential stays + * visible without a stack trace; + * - anything else: `console.error`, as before. + * + * Every level goes through the compact credential-free shape (host and + * pathname only, never the query string) and the shared redactor. + */ +export function logPortalRequestFailure( + channel: string, + expected: PortalRequestFailure | null, + error: unknown, + requestUrl: string, + action?: string +): void { + const details = () => + redactSensitiveData( + formatPortalRequestError(error, requestUrl, action) + ); + + if (expected?.kind === 'cancelled') { + if (isRendererApiTraceEnabled()) { + trace(channel, 'cancelled', details()); + } + return; + } + + if (expected?.kind === 'http') { + console.warn(`[${channel}] Refused`, details()); + return; + } + + console.error(`[${channel}] Failed`, details()); +} diff --git a/apps/electron-backend/src/app/events/stalker.events.spec.ts b/apps/electron-backend/src/app/events/stalker.events.spec.ts index 4cdc2321d..a2f557464 100644 --- a/apps/electron-backend/src/app/events/stalker.events.spec.ts +++ b/apps/electron-backend/src/app/events/stalker.events.spec.ts @@ -1,6 +1,7 @@ import { STALKER_REQUEST, buildHostConnectivityFastFailMessage, + readPortalRequestFailure, } from '@iptvnator/shared/interfaces'; const registeredHandlers = new Map unknown>(); @@ -301,3 +302,195 @@ describe('StalkerEvents host connectivity guard', () => { }); }); }); + +describe('StalkerEvents expected outcomes', () => { + const TRACE_IPC_ENV = 'IPTVNATOR_TRACE_IPC'; + const originalTraceIpc = process.env[TRACE_IPC_ENV]; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + let requestHandler: (...args: unknown[]) => unknown; + + /** axios' rejection once the request's abort signal fired. */ + const cancelled = () => + Object.assign(new Error('canceled'), { + code: 'ERR_CANCELED', + name: 'CanceledError', + }); + + const request = (overrides: Record = {}) => + requestHandler( + { sender: { id: 7 } }, + { + url: PORTAL_URL, + macAddress: MAC_ADDRESS, + token: 'bearer-secret', + params: { + type: 'stb', + action: 'get_profile', + JsHttpRequest: '1-xml', + }, + ...overrides, + } + ) as Promise; + + const allOutput = () => + JSON.stringify([ + ...consoleLogSpy.mock.calls, + ...consoleWarnSpy.mock.calls, + ...consoleErrorSpy.mock.calls, + ]); + + beforeEach(async () => { + jest.resetModules(); + delete process.env[TRACE_IPC_ENV]; + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + axiosMock.isAxiosError.mockImplementation( + (value: unknown) => + !!value && typeof value === 'object' && 'code' in value + ); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + await import('./stalker.events'); + requestHandler = registeredHandlers.get(STALKER_REQUEST) as ( + ...args: unknown[] + ) => unknown; + expect(requestHandler).toBeDefined(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + consoleLogSpy.mockRestore(); + if (originalTraceIpc === undefined) { + delete process.env[TRACE_IPC_ENV]; + } else { + process.env[TRACE_IPC_ENV] = originalTraceIpc; + } + }); + + it('resolves a cancelled request as a structured failure, not a rejection and not an answer', async () => { + axiosMock.mockRejectedValue(cancelled()); + + const result = await request({ + probe: { requestId: 'health', deadlineAt: Date.now() + 5000 }, + }); + + expect(result).toEqual({ portalRequestFailure: { kind: 'cancelled' } }); + expect(readPortalRequestFailure(result)).toEqual({ kind: 'cancelled' }); + // Nothing a consumer could read as portal data. + expect(result).not.toHaveProperty('js'); + // Silent: the renderer asked for it. + expect(allOutput()).toBe('[]'); + }); + + it('traces a cancellation only under the IPC trace flag, without the MAC or query', async () => { + process.env[TRACE_IPC_ENV] = '1'; + axiosMock.mockRejectedValue(cancelled()); + + await request(); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + const line = String(consoleLogSpy.mock.calls[0][0]); + expect(line).toContain('[STALKER_REQUEST] cancelled'); + expect(line).toContain('dead-portal.example.com:8080'); + expect(line).not.toContain('00:1A:79'); + expect(line).not.toContain('bearer-secret'); + expect(line).not.toContain('?'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [401, 'Unauthorized'], + [403, 'Forbidden'], + ])( + 'resolves HTTP %s as a structured failure with one credential-free warning', + async (status, statusText) => { + axiosMock.mockResolvedValue({ + status, + statusText, + data: '', + headers: {}, + }); + + await expect(request()).resolves.toEqual({ + portalRequestFailure: { kind: 'http', status, statusText }, + }); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toBe( + '[STALKER_REQUEST] Refused' + ); + expect(consoleWarnSpy.mock.calls[0][1]).toMatchObject({ + action: 'get_profile', + host: 'dead-portal.example.com:8080', + pathname: '/portal.php', + status, + }); + const output = allOutput(); + expect(output).not.toContain('00:1A:79'); + expect(output).not.toContain('bearer-secret'); + expect(output).not.toContain('portal.php?'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } + ); + + it('still rejects a 404 with its status and logs it as an error', async () => { + // Endpoint discovery reads "probe the next candidate" from this. + axiosMock.mockResolvedValue({ + status: 404, + statusText: 'Not Found', + data: '', + headers: {}, + }); + + await expect(request()).rejects.toThrow('HTTP Error 404: Not Found'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe( + '[STALKER_REQUEST] Failed' + ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'a 5xx', + Object.assign(new Error('Request failed with status code 502'), { + code: 'ERR_BAD_RESPONSE', + response: { status: 502, statusText: 'Bad Gateway' }, + }), + 'HTTP Error 502', + ], + [ + 'a connection failure', + Object.assign(new Error('connect ECONNREFUSED 10.0.0.1:8080'), { + code: 'ECONNREFUSED', + }), + 'ECONNREFUSED', + ], + [ + 'a timeout', + Object.assign(new Error('timeout of 15000ms exceeded'), { + code: 'ECONNABORTED', + }), + 'timeout of 15000ms exceeded', + ], + ])( + 'still rejects %s and logs it as an error', + async (_label, error, message) => { + axiosMock.mockRejectedValue(error); + + await expect(request()).rejects.toThrow(message); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe( + '[STALKER_REQUEST] Failed' + ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index a48170dac..07fdef52a 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -9,16 +9,19 @@ import axios from 'axios'; import { ipcMain } from 'electron'; import { classifyStalkerAuthFailureBody, + createPortalRequestFailureEnvelope, createStalkerAuthFailureMarker, PortalDebugEvent, STALKER_REQUEST, buildStalkerIdentityRequestContext, buildStalkerRequestUrl, } from '@iptvnator/shared/interfaces'; -import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; -import { formatPortalRequestError } from './portal-request-error.util'; +import { + classifyExpectedPortalFailure, + logPortalRequestFailure, +} from './portal-request-outcome'; import { assertRemoteUrlAllowed } from './url-safety'; import { requestWithValidatedRedirects, @@ -156,8 +159,9 @@ ipcMain.handle( // next candidate) from a network failure (stop probing). const httpError = new Error( `HTTP Error ${response.status}: ${response.statusText}` - ) as Error & { status: number }; + ) as Error & { status: number; statusText?: string }; httpError.status = response.status; + httpError.statusText = response.statusText; throw httpError; } @@ -240,16 +244,24 @@ ipcMain.handle( connected: socketConnected, }); - console.error( - '[STALKER_REQUEST] Failed', - redactSensitiveData( - formatPortalRequestError( - error, - requestUrlForLog, - String(payload.params?.action ?? 'unknown') - ) - ) + // A cancelled request and an HTTP 401/403 are routine: resolve + // them as a structured envelope so Electron does not log the + // handler as failed. The renderer's data service rethrows them as + // the errors the Stalker layers classify (an AbortError, or an + // `HTTP Error ` carrying `status`), so a cancellation is + // never read as an empty answer. Everything else keeps the + // rejection shapes below and their error log. + const expected = classifyExpectedPortalFailure(error); + logPortalRequestFailure( + STALKER_REQUEST, + expected, + error, + requestUrlForLog, + String(payload.params?.action ?? 'unknown') ); + if (expected) { + return createPortalRequestFailureEnvelope(expected); + } // Format error response if (axios.isAxiosError(error) && error.response) { diff --git a/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts index 724cdf49b..edd09049e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts @@ -155,6 +155,7 @@ describe('XtreamEvents failed-response performance phases', () => { ).rejects.toEqual({ message: 'HTTP Error: Not Found', status: 404, + statusText: 'Not Found', }); expectFailedResponsePhases(events); } finally { diff --git a/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts index be496f881..11dd25cd5 100644 --- a/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts @@ -222,9 +222,10 @@ describe('XtreamEvents performance phases', () => { await expect( getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort') ).resolves.toEqual({ cancelled: 1, success: true }); - await expect(request).rejects.toMatchObject({ - name: 'AbortError', - status: 499, + // A cancellation resolves as a structured failure (Electron logs + // every rejected handler); the renderer rethrows it as AbortError. + await expect(request).resolves.toEqual({ + portalRequestFailure: { kind: 'cancelled' }, }); expect(signal?.aborted).toBe(true); expect( diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 7365a3267..75653c91b 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -178,25 +178,62 @@ describe('XtreamEvents session cancellation', () => { } ); - it.each([500, 502, 503])('preserves rejected Axios HTTP %s in the health-probe error', async (status) => { - const error = { message: 'Request failed', response: { status } }; - axiosMock.mockRejectedValueOnce(error); - axiosMock.isAxiosError.mockImplementation((value) => value === error); - await expect(registeredHandlers.get('XTREAM_REQUEST')?.( - { sender: { id: 7 } }, - { url: 'https://example.com', params: {}, suppressErrorLog: true, - probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } } - )).rejects.toThrow(`HTTP Error ${status}`); - }); + it.each([500, 502, 503])( + 'preserves rejected Axios HTTP %s in the health-probe error', + async (status) => { + const error = { message: 'Request failed', response: { status } }; + axiosMock.mockRejectedValueOnce(error); + axiosMock.isAxiosError.mockImplementation( + (value) => value === error + ); + await expect( + registeredHandlers.get('XTREAM_REQUEST')?.( + { sender: { id: 7 } }, + { + url: 'https://example.com', + params: {}, + suppressErrorLog: true, + probe: { + requestId: 'status', + deadlineAt: Date.now() + 5000, + }, + } + ) + ).rejects.toThrow(`HTTP Error ${status}`); + } + ); - it.each([401, 403])('preserves HTTP %s in the serialized health-probe error', async (status) => { - axiosMock.mockResolvedValueOnce({ status, statusText: 'refused', headers: {}, data: '' }); - await expect(registeredHandlers.get('XTREAM_REQUEST')?.( - { sender: { id: 7 } }, - { url: 'https://example.com', params: {}, suppressErrorLog: true, - probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } } - )).rejects.toThrow(`HTTP Error ${status}`); - }); + it.each([401, 403])( + 'resolves a health-probe HTTP %s as a structured failure carrying the status', + async (status) => { + axiosMock.mockResolvedValueOnce({ + status, + statusText: 'refused', + headers: {}, + data: '', + }); + await expect( + registeredHandlers.get('XTREAM_REQUEST')?.( + { sender: { id: 7 } }, + { + url: 'https://example.com', + params: {}, + suppressErrorLog: true, + probe: { + requestId: 'status', + deadlineAt: Date.now() + 5000, + }, + } + ) + ).resolves.toEqual({ + portalRequestFailure: { + kind: 'http', + status, + statusText: 'refused', + }, + }); + } + ); it('returns the provider HTTP error without enabling fallback', async () => { axiosMock.mockResolvedValueOnce({ @@ -338,9 +375,11 @@ describe('XtreamEvents session cancellation', () => { pendingRequest.reject(cancelError); - await expect(requestPromise).rejects.toMatchObject({ - name: 'AbortError', - status: 499, + // Resolved, not rejected: Electron logs every rejected handler as an + // error, and a cancellation is the renderer's own doing. The envelope + // is still not an answer — the renderer rethrows it as an AbortError. + await expect(requestPromise).resolves.toEqual({ + portalRequestFailure: { kind: 'cancelled' }, }); }); @@ -420,15 +459,222 @@ describe('XtreamEvents session cancellation', () => { firstRequest.reject(cancelError); secondRequest.reject(cancelError); - await expect(firstPromise).rejects.toMatchObject({ - name: 'AbortError', + await expect(firstPromise).resolves.toEqual({ + portalRequestFailure: { kind: 'cancelled' }, }); - await expect(secondPromise).rejects.toMatchObject({ - name: 'AbortError', + await expect(secondPromise).resolves.toEqual({ + portalRequestFailure: { kind: 'cancelled' }, }); }); }); +describe('XtreamEvents expected outcomes', () => { + const TRACE_IPC_ENV = 'IPTVNATOR_TRACE_IPC'; + const originalTraceIpc = process.env[TRACE_IPC_ENV]; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + let requestHandler: (...args: unknown[]) => unknown; + + /** axios' rejection once the request's abort signal fired. */ + const cancelled = () => + Object.assign(new Error('canceled'), { + code: 'ERR_CANCELED', + name: 'CanceledError', + }); + + const request = (overrides: Record = {}) => + requestHandler( + { sender: { id: 7 } }, + { + url: 'http://panel.example.com:8080', + params: { + action: 'get_live_streams', + password: 'secret-password', + username: 'secret-user', + }, + ...overrides, + } + ) as Promise; + + const allOutput = () => + JSON.stringify([ + ...consoleLogSpy.mock.calls, + ...consoleWarnSpy.mock.calls, + ...consoleErrorSpy.mock.calls, + ]); + + beforeEach(async () => { + jest.resetModules(); + delete process.env[PERF_CAPTURE_ENV]; + delete process.env[TRACE_IPC_ENV]; + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + axiosMock.isAxiosError.mockImplementation( + (value: unknown) => + !!value && typeof value === 'object' && 'code' in value + ); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + await import('./xtream.events'); + requestHandler = registeredHandlers.get('XTREAM_REQUEST') as ( + ...args: unknown[] + ) => unknown; + expect(requestHandler).toBeDefined(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + consoleLogSpy.mockRestore(); + if (originalTraceIpc === undefined) { + delete process.env[TRACE_IPC_ENV]; + } else { + process.env[TRACE_IPC_ENV] = originalTraceIpc; + } + }); + + it('resolves a probe whose deadline already passed as cancelled, silently', async () => { + // The real probe control aborts the signal before axios is reached. + axiosMock.mockImplementation((config: { signal?: AbortSignal }) => { + expect(config.signal?.aborted).toBe(true); + return Promise.reject(cancelled()); + }); + + const result = await request({ + probe: { requestId: 'health', deadlineAt: Date.now() - 1 }, + }); + + expect(result).toEqual({ portalRequestFailure: { kind: 'cancelled' } }); + expect(result).not.toHaveProperty('payload'); + expect(allOutput()).toBe('[]'); + }); + + it('traces a cancellation only under the IPC trace flag, without credentials', async () => { + process.env[TRACE_IPC_ENV] = '1'; + axiosMock.mockRejectedValue(cancelled()); + + await request({ sessionId: 'session-9' }); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + const line = String(consoleLogSpy.mock.calls[0][0]); + expect(line).toContain('[XTREAM_REQUEST] cancelled'); + expect(line).toContain('panel.example.com:8080'); + expect(line).not.toContain('secret-'); + expect(line).not.toContain('?'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [401, 'Unauthorized'], + [403, 'Forbidden'], + ])( + 'resolves HTTP %s as a structured failure with one credential-free warning', + async (status, statusText) => { + axiosMock.mockResolvedValue({ + status, + statusText, + data: '', + headers: {}, + }); + + await expect(request()).resolves.toEqual({ + portalRequestFailure: { kind: 'http', status, statusText }, + }); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toBe( + '[XTREAM_REQUEST] Refused' + ); + expect(consoleWarnSpy.mock.calls[0][1]).toMatchObject({ + action: 'get_live_streams', + host: 'panel.example.com:8080', + pathname: '/player_api.php', + status, + }); + const output = allOutput(); + expect(output).not.toContain('secret-'); + expect(output).not.toContain('player_api.php?'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } + ); + + it('honours suppressErrorLog for the refusal warning too', async () => { + axiosMock.mockResolvedValue({ + status: 401, + statusText: 'Unauthorized', + data: '', + headers: {}, + }); + + await expect(request({ suppressErrorLog: true })).resolves.toEqual({ + portalRequestFailure: { + kind: 'http', + status: 401, + statusText: 'Unauthorized', + }, + }); + expect(allOutput()).toBe('[]'); + }); + + it('still rejects a 404 with its status and logs it as an error', async () => { + axiosMock.mockResolvedValue({ + status: 404, + statusText: 'Not Found', + data: '', + headers: {}, + }); + + await expect(request()).rejects.toMatchObject({ status: 404 }); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe( + '[XTREAM_REQUEST] Failed' + ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'a 5xx', + Object.assign(new Error('Request failed with status code 502'), { + code: 'ERR_BAD_RESPONSE', + response: { status: 502, statusText: 'Bad Gateway', data: {} }, + }), + { status: 502 }, + ], + [ + 'a connection failure', + Object.assign(new Error('connect ECONNREFUSED 10.0.0.1:8080'), { + code: 'ECONNREFUSED', + }), + { message: 'connect ECONNREFUSED 10.0.0.1:8080' }, + ], + [ + 'a timeout', + Object.assign(new Error('timeout of 30000ms exceeded'), { + code: 'ECONNABORTED', + }), + { message: 'timeout of 30000ms exceeded' }, + ], + ])( + 'still rejects %s and logs it as an error', + async (_label, error, shape) => { + axiosMock.mockRejectedValue(error); + + await expect(request()).rejects.toMatchObject(shape); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe( + '[XTREAM_REQUEST] Failed' + ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + } + ); +}); + describe('XtreamEvents host connectivity guard', () => { const GUARD_DISABLED_ENV = 'IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'; const SERVER_URL = 'http://dead-panel.example.com:8080'; @@ -709,9 +955,12 @@ describe('XtreamEvents host connectivity guard', () => { }); axiosMock.mockRejectedValue(cancelled); - await expect(request()).rejects.toMatchObject({ status: 499 }); - await expect(request()).rejects.toMatchObject({ status: 499 }); - await expect(request()).rejects.toMatchObject({ status: 499 }); + const cancelledEnvelope = { + portalRequestFailure: { kind: 'cancelled' }, + }; + await expect(request()).resolves.toEqual(cancelledEnvelope); + await expect(request()).resolves.toEqual(cancelledEnvelope); + await expect(request()).resolves.toEqual(cancelledEnvelope); expect(axiosMock).toHaveBeenCalledTimes(3); }); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 8875a6a2a..d8c511355 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -12,12 +12,15 @@ import { XTREAM_CANCEL_SESSION, XTREAM_CLIENT_USER_AGENT, XTREAM_MAIN_PERFORMANCE_PHASE, + createPortalRequestFailureEnvelope, normalizeXtreamServerUrl, describeXtreamConnectionFailure, } from '@iptvnator/shared/interfaces'; -import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; -import { formatPortalRequestError } from './portal-request-error.util'; +import { + classifyExpectedPortalFailure, + logPortalRequestFailure, +} from './portal-request-outcome'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects, @@ -159,11 +162,14 @@ ipcMain.handle( // Check if response is successful if (response.status >= 400) { - if (payload.probe) throw new Error(`HTTP Error ${response.status}`); - throw { - message: `HTTP Error: ${response.statusText}`, - status: response.status, - }; + // `status` lets the catch block tell a 401/403 (resolved as + // an envelope) from the other 4xx, which keep rejecting. + throw Object.assign( + payload.probe + ? new Error(`HTTP Error ${response.status}`) + : { message: `HTTP Error: ${response.statusText}` }, + { status: response.status, statusText: response.statusText } + ); } if (requestId) { @@ -265,35 +271,34 @@ ipcMain.handle( connected: socketConnected, }); + // A cancelled request and an HTTP 401/403 are routine: resolve + // them as a structured envelope so Electron does not log the + // handler as failed. The renderer's data service rethrows them + // (an AbortError, or an `HTTP Error ` carrying `status`), + // so a cancellation is never read as an empty answer. Everything + // else keeps the rejection shapes below and their error log. + const expected = classifyExpectedPortalFailure(error); if (!payload.suppressErrorLog) { - console.error( - '[XTREAM_REQUEST] Failed', - redactSensitiveData( - formatPortalRequestError( - error, - requestUrlForLog, - payload.params?.action - ) - ) + logPortalRequestFailure( + 'XTREAM_REQUEST', + expected, + error, + requestUrlForLog, + payload.params?.action ); } + if (expected) { + return createPortalRequestFailureEnvelope(expected); + } // Format error response if (axios.isAxiosError(error)) { if (payload.probe) { if (typeof error.response?.status === 'number') throw new Error(`HTTP Error ${error.response.status}`); - throw new Error(error.code === 'ERR_CANCELED' - ? 'Xtream request cancelled' - : error.message || 'Xtream network request failed'); - } - if (error.code === 'ERR_CANCELED') { - throw { - type: 'ERROR', - name: 'AbortError', - message: 'Xtream request cancelled', - status: 499, - }; + throw new Error( + error.message || 'Xtream network request failed' + ); } const errorResponse = { type: 'ERROR', diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 123dcade9..1414ef7d9 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -13,7 +13,9 @@ import { AutoUpdatePlaylistStatus, AutoUpdatePlaylistsResult, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ERROR, PLAYLIST_PARSE_BY_URL, + STALKER_REQUEST, XTREAM_REQUEST, Playlist, SECURITY_ERROR_PREFIX, @@ -24,6 +26,7 @@ describe('ElectronService', () => { const session = { id: 'session-1' }; let electronBridge: { xtreamRequest: jest.Mock; + stalkerRequest: jest.Mock; autoUpdatePlaylists: jest.Mock; fetchPlaylistByUrl: jest.Mock; onPlayerError: jest.Mock; @@ -42,6 +45,7 @@ describe('ElectronService', () => { electronBridge = { xtreamRequest: jest.fn(), + stalkerRequest: jest.fn(), autoUpdatePlaylists: jest.fn(), fetchPlaylistByUrl: jest.fn(), onPlayerError: jest.fn(), @@ -111,8 +115,14 @@ describe('ElectronService', () => { it('protects all startup-refresh sources until the request settles, including failure', async () => { let reject!: (error: Error) => void; - electronBridge.autoUpdatePlaylists.mockReturnValue(new Promise((_, r) => { reject = r; })); - const pending = service.sendIpcEvent(AUTO_UPDATE_PLAYLISTS, [{ _id: 'startup' }]); + electronBridge.autoUpdatePlaylists.mockReturnValue( + new Promise((_, r) => { + reject = r; + }) + ); + const pending = service.sendIpcEvent(AUTO_UPDATE_PLAYLISTS, [ + { _id: 'startup' }, + ]); const activity = TestBed.inject(SourceActivityService); expect(activity.isBusy('startup')).toBe(true); expect(activity.isBusy('other')).toBe(false); @@ -121,25 +131,212 @@ describe('ElectronService', () => { expect(activity.isBusy('startup')).toBe(false); }); - it.each([401, 403])('preserves a health HTTP %s rejection without global error handling', async (status) => { - const error = new Error(`Error invoking remote method: Error: HTTP Error ${status}`); - electronBridge.xtreamRequest.mockRejectedValue(error); - const post = jest.spyOn(window, 'postMessage'); - await expect(service.sendIpcEvent(XTREAM_REQUEST, { - url: 'https://provider.example', params: {}, - probe: { requestId: 'health', deadlineAt: Date.now() + 5000 }, - })).rejects.toBe(error); - expect(snackBar.open).not.toHaveBeenCalled(); - expect(post).not.toHaveBeenCalled(); + it.each([401, 403])( + 'preserves a health HTTP %s rejection without global error handling', + async (status) => { + const error = new Error( + `Error invoking remote method: Error: HTTP Error ${status}` + ); + electronBridge.xtreamRequest.mockRejectedValue(error); + const post = jest.spyOn(window, 'postMessage'); + await expect( + service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: {}, + probe: { + requestId: 'health', + deadlineAt: Date.now() + 5000, + }, + }) + ).rejects.toBe(error); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + } + ); + describe('resolved portal request failures', () => { + // The main process resolves a cancellation or an HTTP 401/403 as this + // envelope instead of rejecting (Electron logs every rejected handler + // as an error); the service must hand its callers the error they + // already classify, never a successful empty answer. + const cancelledEnvelope = { + portalRequestFailure: { kind: 'cancelled' }, + }; + const refusedEnvelope = (status: number) => ({ + portalRequestFailure: { + kind: 'http', + status, + statusText: status === 401 ? 'Unauthorized' : 'Forbidden', + }, + }); + const stalkerPayload = { + url: 'http://portal.example/portal.php', + macAddress: '00:1A:79:00:00:01', + params: { type: 'stb', action: 'get_profile' }, + }; + const xtreamPayload = { + url: 'https://provider.example', + params: { + username: 'user', + password: 'pass', + action: 'get_live_streams', + }, + }; + + it('rethrows a cancelled Stalker request as an AbortError, silently', async () => { + electronBridge.stalkerRequest.mockResolvedValue(cancelledEnvelope); + + await expect( + service.sendIpcEvent(STALKER_REQUEST, stalkerPayload) + ).rejects.toMatchObject({ + name: 'AbortError', + message: 'Stalker request cancelled', + }); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each([401, 403])( + 'rethrows a Stalker HTTP %s carrying the status and reports it', + async (status) => { + electronBridge.stalkerRequest.mockResolvedValue( + refusedEnvelope(status) + ); + + await expect( + service.sendIpcEvent(STALKER_REQUEST, stalkerPayload) + ).rejects.toMatchObject({ + message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`, + status, + }); + expect(snackBar.open).toHaveBeenCalledTimes(1); + expect(snackBar.open.mock.calls[0][0]).toContain( + `status: ${status}` + ); + expect(snackBar.open.mock.calls[0][0]).not.toContain( + 'Error invoking remote method' + ); + } + ); + + it('keeps a silent Stalker probe refusal off the snackbar', async () => { + electronBridge.stalkerRequest.mockResolvedValue( + refusedEnvelope(403) + ); + + await expect( + service.sendIpcEvent(STALKER_REQUEST, { + ...stalkerPayload, + silent: true, + }) + ).rejects.toMatchObject({ status: 403 }); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('rethrows a cancelled Xtream health probe as an AbortError', async () => { + electronBridge.xtreamRequest.mockResolvedValue(cancelledEnvelope); + const post = jest.spyOn(window, 'postMessage'); + + await expect( + service.sendIpcEvent(XTREAM_REQUEST, { + ...xtreamPayload, + probe: { + requestId: 'health', + deadlineAt: Date.now() + 5000, + }, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(post).not.toHaveBeenCalled(); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it.each([401, 403])( + 'rethrows an Xtream health-probe HTTP %s with the status in its message', + async (status) => { + electronBridge.xtreamRequest.mockResolvedValue( + refusedEnvelope(status) + ); + + await expect( + service.sendIpcEvent(XTREAM_REQUEST, { + ...xtreamPayload, + probe: { + requestId: 'health', + deadlineAt: Date.now() + 5000, + }, + }) + ).rejects.toMatchObject({ + message: expect.stringContaining(`HTTP Error ${status}`), + status, + }); + expect(snackBar.open).not.toHaveBeenCalled(); + } + ); + + it('reports a cancelled Xtream request as a silent ERROR result, never a payload', async () => { + electronBridge.xtreamRequest.mockResolvedValue(cancelledEnvelope); + const post = jest.spyOn(window, 'postMessage'); + + const result = await service.sendIpcEvent( + XTREAM_REQUEST, + xtreamPayload + ); + + expect(result).toEqual({ + type: ERROR, + status: 499, + message: 'Xtream request cancelled', + }); + expect(result).not.toHaveProperty('payload'); + expect(post).not.toHaveBeenCalled(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each([401, 403])( + 'turns an Xtream HTTP %s into an ERROR result carrying the status', + async (status) => { + electronBridge.xtreamRequest.mockResolvedValue( + refusedEnvelope(status) + ); + + const result = await service.sendIpcEvent( + XTREAM_REQUEST, + xtreamPayload + ); + + expect(result).toEqual({ + type: ERROR, + status, + message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`, + }); + expect(snackBar.open).toHaveBeenCalledTimes(1); + expect(snackBar.open.mock.calls[0][0]).toContain( + `HTTP Error ${status}` + ); + } + ); + + it('passes an ordinary Stalker answer through untouched', async () => { + const answer = { js: { id: 7, portalRequestFailure: 'field' } }; + electronBridge.stalkerRequest.mockResolvedValue(answer); + + await expect( + service.sendIpcEvent(STALKER_REQUEST, stalkerPayload) + ).resolves.toBe(answer); + }); }); + it('keeps health success request-local without broadcasting catalog responses', async () => { const response = { payload: { user_info: { status: 'Active' } } }; electronBridge.xtreamRequest.mockResolvedValue(response); const post = jest.spyOn(window, 'postMessage'); - expect(await service.sendIpcEvent(XTREAM_REQUEST, { - url: 'https://provider.example', params: {}, - probe: { requestId: 'health', deadlineAt: Date.now() + 5000 }, - })).toEqual(response); + expect( + await service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: {}, + probe: { requestId: 'health', deadlineAt: Date.now() + 5000 }, + }) + ).toEqual(response); expect(post).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index ec2c7e85f..753bbd404 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -13,8 +13,11 @@ import { AUTO_UPDATE_PLAYLISTS, AutoUpdatePlaylistsResult, CONNECTIVITY_GUARD_RESET, + createPortalRequestError, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, ERROR, + isPortalRequestCancelledError, + isPortalRequestFailureEnvelope, normalizeHost, parseSecurityPolicyError, PlayerContentInfo, @@ -316,8 +319,24 @@ export class ElectronService extends DataService { ...payload, requestId: context.requestId, }); + // A cancellation or an HTTP 401/403 arrives resolved — Electron + // would otherwise log each one as a failed handler — and becomes + // the error the Stalker layers classify from `status`/message. + if (isPortalRequestFailureEnvelope(response)) { + throw createPortalRequestError( + response.portalRequestFailure, + 'stalker' + ); + } return response; } catch (err: unknown) { + if (isPortalRequestCancelledError(err)) { + // The renderer asked for this; it is not an error to report. + this.logger.debug( + `Stalker request cancelled (${payload.params?.action ?? 'unknown'})` + ); + throw err; + } const errorInfo = this.getErrorDetails(err); this.logger.error('Stalker request error:', err); if (!payload.silent) { @@ -637,6 +656,15 @@ export class ElectronService extends DataService { ...payload, requestId: context.requestId, }); + // A cancellation or an HTTP 401/403 arrives resolved — Electron + // would otherwise log each one as a failed handler — and becomes + // the error this method already reports below. + if (isPortalRequestFailureEnvelope(response)) { + throw createPortalRequestError( + response.portalRequestFailure, + 'xtream' + ); + } if (payload.connectionTest || payload.probe) return response; @@ -650,7 +678,11 @@ export class ElectronService extends DataService { } catch (error: unknown) { if (payload.probe) throw error; const action = payload.params?.action; + // The renderer asked for a cancellation; report it like a + // background action, not a failure the user needs to see. + const cancelled = isPortalRequestCancelledError(error); const isSilentAction = + cancelled || payload.suppressErrorLog === true || (action ? this.silentXtreamActions.has(action) : false); const normalizedMessage = this.getReadableXtreamErrorMessage(error); @@ -679,7 +711,9 @@ export class ElectronService extends DataService { return { type: ERROR, - status: errorInfo?.status ?? 500, + // 499 ("client closed request") keeps a cancellation apart + // from a server failure in the result callers log. + status: cancelled ? 499 : (errorInfo?.status ?? 500), message: normalizedMessage, }; } diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 82c0de56a..ec723c8da 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -753,6 +753,31 @@ classifier accepts both shapes plus the legacy `{ js: '' }` envelope. `makeAuthenticatedRequest` retries once with fresh authentication and otherwise throws `StalkerPortalError('auth-failed')` carrying the body. +### Cancelled requests and HTTP 401/403 over IPC + +Electron prints `Error occurred in handler for 'STALKER_REQUEST'` — with a +stack trace — for every `ipcMain.handle` promise that rejects, so the main +process resolves two routine outcomes instead of throwing them: a request whose +abort signal fired (a source-health probe the renderer cancelled or let +expire) and an HTTP 401/403 (the endpoint answered and refused — an auth gate +in front of a discovery candidate, an expired account). Both come back as a +`{ portalRequestFailure }` envelope +(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified +in `apps/electron-backend/src/app/events/portal-request-outcome.ts`). +`ElectronService.fetchStalkerData` is the only reader of the raw bridge result +and rethrows the envelope as the error the Stalker layers already classify: an +`AbortError` for the cancellation (debug log, no snackbar), or an +`HTTP Error : ` Error that carries a numeric `status`, so +`getStalkerRequestErrorStatus` no longer has to parse it out of the message. +A cancelled request therefore never resolves into an empty answer. In the main +process the refusal is one `console.warn` through `formatPortalRequestError` +and `redactSensitiveData` (host and pathname, never the MAC, token or query +string); the cancellation is logged only under `IPTVNATOR_TRACE_IPC`. Every +other failure — 404, 5xx, network errors, the connectivity guard's fast-fail — +still rejects with the message contracts above and keeps its `console.error`. +`apps/electron-backend-e2e/src/portal-request-logging.e2e.ts` pins both +outcomes for both portals against the real main-process log. + ### Error surfacing `StalkerPortalError.portalText` holds the portal's own words. The import diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index bf3fb37da..38e83ef43 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -100,6 +100,22 @@ connection failures, never reported as provider HTTP statuses or used to authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery. Provider JSON remains nested in `payload` and cannot provide this evidence. +Electron resolves two more outcomes instead of rejecting them, because Electron +logs every rejected `ipcMain.handle` promise as a handler error with a stack +trace: a cancelled request (`XTREAM_CANCEL_SESSION`, a cancelled or expired +source-health probe) and an HTTP 401/403. Both return a +`{ portalRequestFailure }` envelope +(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified in +`apps/electron-backend/src/app/events/portal-request-outcome.ts`) that +`ElectronService.forwardXtreamRequest` turns back into what its callers already +handle: health probes get an `AbortError` or an `HTTP Error ` Error +carrying `status`; catalog calls get the usual `{ type: ERROR, status, message }` +result — status 499 and no snackbar for a cancellation, the real 401/403 +(previously lost as `[object Object]`) for a refusal. The main process logs a +refusal as one credential-free `console.warn` (unless `suppressErrorLog`), a +cancellation only under `IPTVNATOR_TRACE_IPC`, and everything else as before +at error level with a rejection. + The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL construction and fresh Favorites/Recent resolution. The routed Xtream session observes metadata connection changes and bootstraps the new connection. Separate diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index 76382e529..3337fb81b 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -24,7 +24,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend ``` - Narrower trace flags: - - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls + - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls and reports cancelled Stalker/Xtream portal requests, which are otherwise not logged (an HTTP 401/403 is always one `Refused` warning; other failures stay errors) - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts index 57098961d..de41a076d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts @@ -161,7 +161,8 @@ export function classifyStalkerProbeResponse( * but `ipcRenderer.invoke` strips every custom property from a rejected * value and re-wraps the message, so in the renderer the numeric `status` * field usually does NOT survive and the code must be parsed back out of - * the message text. + * the message text. Only a 401/403 arrives resolved and is rebuilt by + * `ElectronService` with its `status` (see `portal-request-failure.util.ts`). */ export function getStalkerRequestErrorStatus( error: unknown diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 4b5fedb14..fbaa3d071 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -109,5 +109,6 @@ export * from './lib/vod-details-item.interface'; export * from './lib/catchup-download.interface'; export * from './lib/xtream-connection-test'; +export * from './lib/portal-request-failure.util'; export * from './lib/source-health'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index c9d3ecd95..ec330e70e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1,5 +1,6 @@ import type { AppUpdateChannel } from './app-update-channel.util'; import type { SourceProbeContext, SourceHealthResult } from './source-health'; +import type { PortalRequestFailureEnvelope } from './portal-request-failure.util'; import type { XtreamConnectionFailure } from './xtream-connection-test'; import type { ZoomLevelAction } from './zoom-level.util'; import type { @@ -339,6 +340,17 @@ export interface ElectronBridgeXtreamResponse { action: string; } +/** + * What `xtreamRequest` resolves with: the provider answer, or — for a + * cancelled request and an HTTP 401/403 — a `portalRequestFailure` envelope + * carrying neither `payload` nor `action`. Resolved instead of rejected so + * Electron does not log the handler as failed; `ElectronService` narrows it + * with `isPortalRequestFailureEnvelope` and rethrows + * (`portal-request-failure.util.ts`). + */ +export type ElectronBridgeXtreamResult = + ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope; + export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult { cancelled: number; } @@ -912,9 +924,13 @@ export interface ElectronBridgeApi { getAiSettings: () => Promise; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; setVlcPlayerPath: (vlcPlayerPath: string) => Promise; + /** + * The portal's JSON, the `{ stalkerAuthFailure }` marker, or a + * `PortalRequestFailureEnvelope` (cancelled request, HTTP 401/403). + */ stalkerRequest: ( payload: ElectronBridgeStalkerRequestPayload - ) => Promise>; + ) => Promise | PortalRequestFailureEnvelope>; /** * Forgets the connection failures recorded for the host `url` points at, so * the next request contacts it for real instead of being fast-failed. @@ -922,7 +938,7 @@ export interface ElectronBridgeApi { resetHostConnectivityGuard: (url: string) => Promise; xtreamRequest: ( payload: ElectronBridgeXtreamRequestPayload - ) => Promise; + ) => Promise; xtreamCancelSession: ( sessionId: string ) => Promise; diff --git a/libs/shared/interfaces/src/lib/portal-request-failure.util.spec.ts b/libs/shared/interfaces/src/lib/portal-request-failure.util.spec.ts new file mode 100644 index 000000000..846cb0a60 --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-request-failure.util.spec.ts @@ -0,0 +1,173 @@ +import { isStalkerAuthFailureMessage } from './stalker-auth-failure.util'; +import { + createPortalRequestError, + createPortalRequestFailureEnvelope, + formatPortalHttpErrorMessage, + isExpectedPortalHttpStatus, + isPortalRequestCancelledError, + isPortalRequestFailureEnvelope, + readPortalRequestFailure, +} from './portal-request-failure.util'; +import { sourceHealthError } from './source-health'; + +describe('portal request failure envelope', () => { + it('round-trips a cancellation', () => { + const envelope = createPortalRequestFailureEnvelope({ + kind: 'cancelled', + }); + + expect(readPortalRequestFailure(envelope)).toEqual({ + kind: 'cancelled', + }); + }); + + it('round-trips an HTTP refusal with and without a status text', () => { + expect( + readPortalRequestFailure( + createPortalRequestFailureEnvelope({ + kind: 'http', + status: 401, + statusText: 'Unauthorized', + }) + ) + ).toEqual({ kind: 'http', status: 401, statusText: 'Unauthorized' }); + expect( + readPortalRequestFailure( + createPortalRequestFailureEnvelope({ + kind: 'http', + status: 403, + }) + ) + ).toEqual({ kind: 'http', status: 403 }); + }); + + it.each([ + ['an ordinary payload', { js: { data: [] } }], + [ + 'a payload reusing the key with another shape', + { + portalRequestFailure: { kind: 'http' }, + }, + ], + ['an unknown kind', { portalRequestFailure: { kind: 'timeout' } }], + [ + 'a non-string status text', + { + portalRequestFailure: { + kind: 'http', + status: 401, + statusText: 123, + }, + }, + ], + [ + 'a non-numeric status', + { portalRequestFailure: { kind: 'http', status: '401' } }, + ], + ['a string', 'Authorization failed.'], + ['null', null], + ['undefined', undefined], + ])('treats %s as a normal response', (_label, value) => { + expect(readPortalRequestFailure(value)).toBeNull(); + }); + + it('narrows a bridge result to the envelope only for a valid failure', () => { + const envelope = createPortalRequestFailureEnvelope({ + kind: 'http', + status: 403, + }); + const result: typeof envelope | { payload: unknown; action: string } = + envelope; + + expect(isPortalRequestFailureEnvelope(result)).toBe(true); + if (isPortalRequestFailureEnvelope(result)) { + // Narrowed: the success fields are no longer on the type. + expect(result.portalRequestFailure.kind).toBe('http'); + } + expect( + isPortalRequestFailureEnvelope({ payload: [], action: 'get' }) + ).toBe(false); + expect( + isPortalRequestFailureEnvelope({ + portalRequestFailure: { kind: 'http' }, + }) + ).toBe(false); + // The guard vouches for every field a caller may read off the + // original value, so a malformed status text fails it too. + expect( + isPortalRequestFailureEnvelope({ + portalRequestFailure: { + kind: 'http', + status: 401, + statusText: 123, + }, + }) + ).toBe(false); + }); + + it('expects only the HTTP auth refusals', () => { + expect([401, 403].every(isExpectedPortalHttpStatus)).toBe(true); + expect( + [200, 400, 404, 429, 500, 502, undefined, '401'].some( + isExpectedPortalHttpStatus + ) + ).toBe(false); + }); +}); + +describe('createPortalRequestError', () => { + it('turns a cancellation into an AbortError, never an empty answer', () => { + const error = createPortalRequestError( + { kind: 'cancelled' }, + 'stalker' + ); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('AbortError'); + expect(error.message).toBe('Stalker request cancelled'); + expect(isPortalRequestCancelledError(error)).toBe(true); + // The health probes read the reason from the message. + expect(sourceHealthError(error).reason).toBe('cancelled'); + // A cancellation is not a timeout, an auth failure or an HTTP answer. + expect(/timed out|timeout|HTTP Error/i.test(error.message)).toBe(false); + expect(isStalkerAuthFailureMessage(error.message)).toBe(false); + }); + + it.each([ + [401, 'Unauthorized', 'HTTP Error 401: Unauthorized'], + [403, undefined, 'HTTP Error 403'], + [403, ' ', 'HTTP Error 403'], + ])( + 'turns an HTTP %s refusal into the message the classifiers parse', + (status, statusText, message) => { + const error = createPortalRequestError( + { kind: 'http', status, statusText }, + 'xtream' + ) as Error & { status?: number }; + + expect(error.message).toBe(message); + expect(error.status).toBe(status); + expect(isPortalRequestCancelledError(error)).toBe(false); + expect(sourceHealthError(error)).toMatchObject({ + reason: 'auth', + state: 'inactive', + }); + } + ); + + it('formats the HTTP message once for both processes', () => { + expect(formatPortalHttpErrorMessage(401, 'Unauthorized')).toBe( + 'HTTP Error 401: Unauthorized' + ); + expect(formatPortalHttpErrorMessage(404)).toBe('HTTP Error 404'); + }); + + it('does not read a plain error as a cancellation', () => { + expect(isPortalRequestCancelledError(new Error('canceled'))).toBe( + false + ); + expect(isPortalRequestCancelledError({ name: 'AbortError' })).toBe( + false + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/portal-request-failure.util.ts b/libs/shared/interfaces/src/lib/portal-request-failure.util.ts new file mode 100644 index 000000000..72bbf85e0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-request-failure.util.ts @@ -0,0 +1,154 @@ +/** + * Expected portal-request failures, carried across the Electron IPC boundary + * as a RESOLVED value. + * + * `ipcMain.handle` logs every rejected handler as + * `Error occurred in handler for ''` with a stack trace, and + * `ipcRenderer.invoke` keeps nothing of the rejection but its message. Two + * outcomes of a portal request are routine rather than errors, so they must + * not take that path: + * + * - a **cancelled** request — the renderer navigated away, superseded it, or + * let its probe deadline pass; and + * - an HTTP **401/403** — the portal answered and refused, which the renderer + * already classifies structurally (`isStalkerAuthorizationFailure`, + * `sourceHealthError`, the lazy portal repair). + * + * The main process resolves them as this envelope. The renderer's Electron + * data service is the only consumer of the raw bridge result; it turns the + * envelope back into the thrown error its callers already understand — an + * `AbortError`, or an `HTTP Error ` message with a numeric `status` — + * so a cancelled request can never be mistaken for a successful empty answer. + * + * This lives in `shared/interfaces` for the same reason as the Stalker + * auth-failure marker: the main process cannot import renderer libraries, and + * two copies would drift. + */ + +export const PORTAL_REQUEST_FAILURE_KEY = 'portalRequestFailure'; + +/** HTTP statuses that mean "the portal answered and refused", not "broken". */ +export const EXPECTED_PORTAL_HTTP_STATUSES: readonly number[] = [401, 403]; + +export type PortalRequestFailure = + | { kind: 'cancelled' } + | { kind: 'http'; status: number; statusText?: string }; + +export interface PortalRequestFailureEnvelope { + [PORTAL_REQUEST_FAILURE_KEY]: PortalRequestFailure; +} + +export type PortalRequestProvider = 'stalker' | 'xtream'; + +const PROVIDER_LABEL: Record = { + stalker: 'Stalker', + xtream: 'Xtream', +}; + +export function isExpectedPortalHttpStatus(status: unknown): status is number { + return ( + typeof status === 'number' && + EXPECTED_PORTAL_HTTP_STATUSES.includes(status) + ); +} + +export function createPortalRequestFailureEnvelope( + failure: PortalRequestFailure +): PortalRequestFailureEnvelope { + return { [PORTAL_REQUEST_FAILURE_KEY]: failure }; +} + +/** + * The failure a bridge result carries, or null for an ordinary response. + * Strict on shape: a portal payload that happens to contain the key with + * anything else in it is still a payload. + */ +export function readPortalRequestFailure( + value: unknown +): PortalRequestFailure | null { + if (!value || typeof value !== 'object') { + return null; + } + + const failure = (value as Record)[ + PORTAL_REQUEST_FAILURE_KEY + ]; + if (!failure || typeof failure !== 'object') { + return null; + } + + const { kind, status, statusText } = failure as Record; + if (kind === 'cancelled') { + return { kind }; + } + + if (kind !== 'http' || typeof status !== 'number') { + return null; + } + + // `statusText` must be a string or absent: the guard below vouches for the + // whole shape, and a caller that reads the original value must not meet a + // number where the type promises a string. + if (statusText === undefined) { + return { kind, status }; + } + + return typeof statusText === 'string' ? { kind, status, statusText } : null; +} + +/** + * Type guard for a bridge result: narrows the union the bridge promises + * (`ElectronBridgeXtreamResult`) so success fields cannot be read off an + * envelope without checking first. + */ +export function isPortalRequestFailureEnvelope( + value: unknown +): value is PortalRequestFailureEnvelope { + return readPortalRequestFailure(value) !== null; +} + +/** + * The message shape the renderer classifies HTTP failures from + * (`getStalkerRequestErrorStatus`, `isStalkerAuthorizationFailure`, + * `sourceHealthError`): `HTTP Error `, with the status text when the + * portal sent one. + */ +export function formatPortalHttpErrorMessage( + status: number, + statusText?: string +): string { + const text = statusText?.trim(); + return text ? `HTTP Error ${status}: ${text}` : `HTTP Error ${status}`; +} + +/** + * Rebuilds the error the renderer expects from a resolved envelope. + * + * A cancellation is an `AbortError`, the convention the database and refresh + * paths already use (`isDbAbortError`). An HTTP refusal carries the numeric + * `status` — which the old rejection lost at the IPC boundary — and the + * message the classifiers parse. + */ +export function createPortalRequestError( + failure: PortalRequestFailure, + provider: PortalRequestProvider +): Error { + if (failure.kind === 'cancelled') { + const error = new Error( + `${PROVIDER_LABEL[provider]} request cancelled` + ); + error.name = 'AbortError'; + return error; + } + + const error = new Error( + formatPortalHttpErrorMessage(failure.status, failure.statusText) + ) as Error & { status: number }; + error.status = failure.status; + return error; +} + +/** Whether an error is a cancelled portal request (or any other abort). */ +export function isPortalRequestCancelledError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +}