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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 14:39:21 +02:00
1 parent 614b0d3500
commit 769ed4b246
6 files changed
+179 -26

No files matched your search

@@ -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<unknown>
).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,
+48 -19
View File
@@ -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<Response> {
const token = this.serverAccessToken(url);
let response: Response;
try {
response = await fetch(url, {
...init,
headers: {
...(init.headers as Record<string, string> | 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;
}
}
+5 -2
View File
@@ -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
@@ -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,
@@ -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', () => {
@@ -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')