From 769ed4b24614437412f3d712206d0234b4b340eb Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 14:39:21 +0200 Subject: [PATCH] fix(server-storage): interceptor and raw fetches report backend outages too Only the storage API client reported an unreachable backend as offline. When the first sign of an outage was an HttpClient call through the interceptor (provider targets, playlist parsing) or one of PwaService's raw fetches (Stalker, connectivity reset), the instance stayed verified, and the next request after a replacement instance came up at the same address would carry the old token. Both now report no answer (status 0, a failed fetch) and a gateway's 502/503/504 as offline, so the session checks /instance first. The backend's own JSON 502 for a failed provider is an answer and changes nothing; neither does a fetch this page aborted on its own timeout. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/services/pwa.service.spec.ts | 63 +++++++++++++++++ apps/web/src/app/services/pwa.service.ts | 67 +++++++++++++------ docs/architecture/pwa-self-hosted.md | 7 +- libs/services/src/lib/server-access-token.ts | 14 ++++ .../server-access-token.interceptor.spec.ts | 28 ++++++++ .../server-access-token.interceptor.ts | 26 +++++-- 6 files changed, 179 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index d978b975a..6dba8849a 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -669,6 +669,69 @@ describe('PwaService', () => { } }); + it('reports a backend outage seen by its raw fetches', async () => { + const status = TestBed.inject(ServerStorageStatusService); + const activate = () => + status.activate( + { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', + }, + () => undefined + ); + const originalFetch = globalThis.fetch; + const reset = () => + ( + service.sendIpcEvent(CONNECTIVITY_GUARD_RESET, { + url: 'http://portal.example:8080/portal.php', + }) as Promise + ).catch(() => undefined); + const answer = (code: number, contentType: string) => + ({ + ok: false, + status: code, + headers: new Headers({ 'content-type': contentType }), + json: async () => ({}), + }) as unknown as Response; + + try { + activate(); + // The backend's own JSON error: it answered. + globalThis.fetch = jest + .fn() + .mockResolvedValue( + answer(502, 'application/json') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('online'); + + // A gateway's page in front of a backend that is down. + globalThis.fetch = jest + .fn() + .mockResolvedValue( + answer(502, 'text/html') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('offline'); + expect(status.isVerified()).toBe(false); + + // No answer at all. + activate(); + globalThis.fetch = jest + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch') + ) as unknown as typeof fetch; + await reset(); + expect(status.state()).toBe('offline'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('asks the backend to forget a host when the connectivity guard is reset', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 0c4f389fb..b18fa6b7f 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -15,6 +15,7 @@ import { } from 'rxjs'; import { DataService, + isGatewayOutage, isServerAccessChallenge, ServerStorageStatusService, } from '@iptvnator/services'; @@ -210,18 +211,15 @@ export class PwaService extends DataService { ); try { - const url = `${this.corsProxyUrl}/connectivity-guard/reset`; - const token = this.serverAccessToken(url); - const response = await fetch(url, { - body: JSON.stringify({ url: payload.url }), - headers: { - 'content-type': 'application/json', - ...bearer(token), - }, - method: 'POST', - signal: controller.signal, - }); - this.noteServerAccess(response, token); + const response = await this.backendFetch( + `${this.corsProxyUrl}/connectivity-guard/reset`, + { + body: JSON.stringify({ url: payload.url }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + signal: controller.signal, + } + ); if (!response.ok) { return { reset: false }; @@ -663,11 +661,7 @@ export class PwaService extends DataService { logPortalDebugRequest(context); // Make the fetch request - const accessToken = this.serverAccessToken(requestUrl); - const response = await fetch(requestUrl, { - headers: bearer(accessToken), - }); - this.noteServerAccess(response, accessToken); + const response = await this.backendFetch(requestUrl, {}); if (!response.ok) { throw new Error( @@ -831,9 +825,43 @@ export class PwaService extends DataService { : null; } - /** A token changed on the server shows up on any backend call. */ - private noteServerAccess(response: Response, token: string | null): void { + /** + * `fetch` to the backend with the server token, telling the server + * session what any backend call can show: a token changed on the + * server, or an outage (no answer, or a gateway's 502/503/504), after + * which the instance is checked before the token goes out again. A + * timeout of this page's own is no outage: the backend may be slow. + */ + private async backendFetch( + url: string, + init: RequestInit + ): Promise { + const token = this.serverAccessToken(url); + let response: Response; + try { + response = await fetch(url, { + ...init, + headers: { + ...(init.headers as Record | undefined), + ...bearer(token), + }, + }); + } catch (error) { + if (!( + error instanceof DOMException && error.name === 'AbortError' + )) { + this.serverStatus.reportOffline(); + } + throw error; + } if ( + isGatewayOutage( + response.status, + response.headers?.get('content-type') + ) + ) { + this.serverStatus.reportOffline(); + } else if ( response.status === 401 && isServerAccessChallenge( response.status, @@ -842,6 +870,7 @@ export class PwaService extends DataService { ) { this.serverStatus.reportAccessDenied(token); } + return response; } } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index d0869dc5a..bb360ee3e 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -335,8 +335,11 @@ settings and guards read them in every build. therefore remembers the server along with the token, or reports storage trouble. A page started from memory first checks that the instance at the address is the remembered one (`ensureVerified`), and reloads if not, - before sign-in sends the token. So does a page that went offline before - its next read: until then it sends neither the token nor writes (a + before sign-in sends the token. So does a page that went offline (any + backend call can tell: the storage API, `HttpClient` calls through the + interceptor and `PwaService`'s raw fetches report no answer, or a + gateway's 502/503/504 that is not the backend's own JSON, as offline) + before its next read: until then it sends neither the token nor writes (a replaced volume at the same address is another database), and it checks the instance as soon as it goes offline, so a blip costs no retry interval (only the instance: a read that keeps failing waits for the diff --git a/libs/services/src/lib/server-access-token.ts b/libs/services/src/lib/server-access-token.ts index aac90e92e..95810ff3e 100644 --- a/libs/services/src/lib/server-access-token.ts +++ b/libs/services/src/lib/server-access-token.ts @@ -2,6 +2,20 @@ import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces'; export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token'; +/** + * A gateway's answer for a backend that is down (nginx's 502/503/504 page), + * not the backend's own JSON error for a provider that failed. + */ +export function isGatewayOutage( + status: number, + contentType: string | null | undefined +): boolean { + return ( + [502, 503, 504].includes(status) && + !contentType?.includes('application/json') + ); +} + /** A 401 from the backend's access guard, not one a provider sent. */ export function isServerAccessChallenge( status: number, diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts index 58ad04a05..71835f760 100644 --- a/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts @@ -126,6 +126,34 @@ describe('ServerAccessTokenInterceptor', () => { localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY) ).toBeNull(); }); + + it('reports a backend that does not answer as offline, not a provider failing', () => { + const status = TestBed.inject(ServerStorageStatusService); + activate(); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + // The backend's own JSON error for a provider that failed. + http.get('/api/xtream').subscribe({ error: () => undefined }); + controller.expectOne('/api/xtream').flush( + { message: 'provider down' }, + { + status: 502, + statusText: 'Bad Gateway', + headers: { 'Content-Type': 'application/json' }, + } + ); + expect(status.state()).toBe('online'); + expect(status.isVerified()).toBe(true); + + // No answer at all: it may come back as another instance. + http.get('/api/parse').subscribe({ error: () => undefined }); + controller + .expectOne('/api/parse') + .error(new ProgressEvent('error'), { status: 0 }); + expect(status.state()).toBe('offline'); + expect(status.accessToken()).toBeNull(); + }); }); describe('isBackendUrl', () => { diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts index 63ef61fc5..7e9446a2d 100644 --- a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts @@ -7,14 +7,18 @@ import { } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable, tap } from 'rxjs'; -import { isServerAccessChallenge } from '../server-access-token'; +import { + isGatewayOutage, + isServerAccessChallenge, +} from '../server-access-token'; import { ServerStorageStatusService } from '../server-storage-status.service'; import { SERVER_STORAGE_CONFIG } from './server-storage.config'; /** - * Adds the server access token to `HttpClient` calls to the backend, and - * sends the user to sign-in when the backend refuses it (a token changed - * on the server while this page was open). + * Adds the server access token to `HttpClient` calls to the backend, sends + * the user to sign-in when the backend refuses it (a token changed on the + * server while this page was open), and reports a backend that does not + * answer as offline, like the storage API client does. */ @Injectable() export class ServerAccessTokenInterceptor implements HttpInterceptor { @@ -37,8 +41,20 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor { return next.handle(sent).pipe( tap({ error: (error: unknown) => { + if (!(error instanceof HttpErrorResponse)) { + return; + } if ( - error instanceof HttpErrorResponse && + error.status === 0 || + isGatewayOutage( + error.status, + error.headers.get('Content-Type') + ) + ) { + // No answer: it may come back as another instance, + // checked before the token goes out again. + this.status.reportOffline(); + } else if ( isServerAccessChallenge( error.status, error.headers.get('WWW-Authenticate')