mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user