mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
231 lines
8.8 KiB
TypeScript
231 lines
8.8 KiB
TypeScript
/**
|
|
* Request timeouts and the per-host circuit breaker for the proxy routes.
|
|
*
|
|
* Both exist for the same reason. Every outbound call here used to be a bare
|
|
* `axios.get()` with no `timeout`, so a provider host that accepts a connection
|
|
* and then goes silent held the request until the OS gave up on the TCP
|
|
* connection — minutes, not seconds, and the browser tab waited for all of it.
|
|
* The Electron handlers have always passed an explicit timeout; these are the
|
|
* same numbers, so the PWA and the desktop app give up at the same point.
|
|
*
|
|
* A timeout alone only bounds a single request. Browsing a dead portal issues
|
|
* dozens back to back, so the breaker (shared with the Electron main process,
|
|
* see `@iptvnator/shared/host-health`) fast-fails the rest once a host has
|
|
* refused to answer twice in a row.
|
|
*
|
|
* On the axios timeout semantics: with axios 1.20's native HTTP
|
|
* transport (`maxRedirects: 0`), each hop's `timeout` is NOT a wall-clock
|
|
* deadline for the whole response. It bounds the time to response headers, and then continues as the socket's
|
|
* inactivity timeout for the body. A large XMLTV or M3U download that keeps
|
|
* delivering bytes is therefore never cut off mid-transfer — only a stalled one
|
|
* is, which is exactly the case these numbers are meant to catch.
|
|
*/
|
|
|
|
import {
|
|
HostConnectivityGuard,
|
|
HostRequestToken,
|
|
classifyHostRequestFailure,
|
|
failedAfterRedirect,
|
|
portalEndpointKeyOf,
|
|
} from '@iptvnator/shared/host-health';
|
|
import { buildHostConnectivityFastFailMessage } from '@iptvnator/shared/interfaces';
|
|
import { ProviderRequestError } from './provider-request-error';
|
|
import type { NormalizedProviderError } from './provider-error';
|
|
|
|
/**
|
|
* Per-route request timeouts, matching the Electron handlers so both runtimes
|
|
* give up at the same point:
|
|
*
|
|
* - `xtream` — `xtream.events.ts` (30 s for the Xtream API)
|
|
* - `stalker` / `stalkerCreateLink` — `stalker.events.ts`; `create_link` gets
|
|
* longer because the portal mints a stream URL before answering
|
|
* - `playlist` — `PLAYLIST_FETCH_TIMEOUT_MS` in `playlist-source.ts`
|
|
* - `epg` — the EPG worker streams its download with no explicit timeout, so
|
|
* there is no number to copy; it takes the playlist budget, which bounds a
|
|
* silent host without capping a healthy long transfer (see the note above).
|
|
*/
|
|
export const PROVIDER_REQUEST_TIMEOUT_MS = {
|
|
epg: 30_000,
|
|
playlist: 30_000,
|
|
stalker: 15_000,
|
|
stalkerCreateLink: 30_000,
|
|
xtream: 30_000,
|
|
} as const;
|
|
|
|
/** Status used for a fast-fail, matching `normalizeProviderError`'s upstream-unreachable answer. */
|
|
const HOST_UNREACHABLE_STATUS = 502;
|
|
|
|
export type HostRequestAdmission =
|
|
| { readonly allowed: true; readonly token: HostRequestToken | null }
|
|
| { readonly allowed: false; readonly error: NormalizedProviderError };
|
|
|
|
/**
|
|
* Whether a request to `url` may go out.
|
|
*
|
|
* A refusal carries the ordinary `{ message, status }` provider-error body, so
|
|
* every route keeps answering in the shape its client already parses. The
|
|
* message itself is the shared one from `@iptvnator/shared/interfaces` — the
|
|
* Stalker renderer classifies transport failures from message text alone, and
|
|
* that wording is the only one it reads as "connection-level failure" rather
|
|
* than as a status, a timeout or an auth problem.
|
|
*
|
|
* A URL with no usable host is admitted untracked: there is nothing to key on,
|
|
* and refusing over it would be worse than letting the transport report the
|
|
* real problem.
|
|
*/
|
|
export function admitProviderRequest(
|
|
guard: HostConnectivityGuard,
|
|
url: string
|
|
): HostRequestAdmission {
|
|
const endpoint = portalEndpointKeyOf(url);
|
|
if (!endpoint) {
|
|
return { allowed: true, token: null };
|
|
}
|
|
|
|
const check = guard.check(endpoint);
|
|
if (!check.allowed) {
|
|
return {
|
|
allowed: false,
|
|
error: {
|
|
message: buildHostConnectivityFastFailMessage(endpoint),
|
|
status: HOST_UNREACHABLE_STATUS,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { allowed: true, token: check.token };
|
|
}
|
|
|
|
/**
|
|
* Gives a token back without recording anything about the host.
|
|
*
|
|
* For a request that was admitted and then abandoned before it went out — a URL
|
|
* the SSRF policy refused, for instance. That says nothing about reachability,
|
|
* so it must not count as a failure; but the slot a half-open trial reserved
|
|
* has to be released. Every admitted route also calls this in finally, even
|
|
* if outcome reporting throws; cleanup is idempotent and records no failure.
|
|
*/
|
|
export function releaseProviderRequest(
|
|
guard: HostConnectivityGuard,
|
|
token: HostRequestToken | null
|
|
): void {
|
|
if (token) {
|
|
guard.reportInconclusive(token);
|
|
}
|
|
}
|
|
|
|
/** The host answered — whatever the status was, it is reachable. */
|
|
export function reportProviderRequestSuccess(
|
|
guard: HostConnectivityGuard,
|
|
token: HostRequestToken | null
|
|
): void {
|
|
if (token) {
|
|
guard.reportSuccess(token);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A token for a request that must NOT be policed or counted, but whose success
|
|
* still clears the record.
|
|
*
|
|
* Stalker endpoint discovery probes several candidate paths on one host and
|
|
* expects most of them to fail; counting those would let it declare a
|
|
* slow-but-alive portal unreachable, and fast-failing them would abandon a
|
|
* portal mid-discovery. The probe does not take the half-open slot either — it
|
|
* is not the trial the breaker is waiting for. Mirrors the Electron handler's
|
|
* `skipConnectionGuard` path.
|
|
*/
|
|
export function observeProviderRequest(
|
|
guard: HostConnectivityGuard,
|
|
url: string
|
|
): HostRequestToken | null {
|
|
const endpoint = portalEndpointKeyOf(url);
|
|
return endpoint ? guard.observe(endpoint) : null;
|
|
}
|
|
|
|
/**
|
|
* Forgets the failures recorded for the host `url` points at, so the next
|
|
* request contacts it for real. Sent by the renderer whenever the user asked
|
|
* for a genuine attempt (portal retry, "test connection") or handed over an
|
|
* address that may now point somewhere else (import, edited connection, lazy
|
|
* repair) — the counterpart of the Electron `CONNECTIVITY_GUARD_RESET` handler.
|
|
*
|
|
* Returns whether a host could be read from `url` at all.
|
|
*/
|
|
export function resetProviderHost(
|
|
guard: HostConnectivityGuard,
|
|
url: string
|
|
): boolean {
|
|
const endpoint = portalEndpointKeyOf(url);
|
|
if (!endpoint) {
|
|
return false;
|
|
}
|
|
|
|
guard.reset(endpoint);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Records what a failed request proved about its endpoint.
|
|
*
|
|
* `requestUrl` is the URL the request was actually issued against. It is what
|
|
* lets a failure on a redirect hop be told apart from a failure of the endpoint
|
|
* we guarded: a provider that answers `302` towards a dead CDN is demonstrably
|
|
* alive, and charging the destination's refusal to it would fast-fail a working
|
|
* portal. Same rule and same helper as the Electron handlers.
|
|
*
|
|
* `countFailures: false` is for requests exempt from the guard (endpoint
|
|
* discovery). Their failures are expected and must not count — but the report
|
|
* must still happen, because an error that carries an HTTP response proves the
|
|
* endpoint answered. The redirect wrapper accepts 3xx, but axios still rejects
|
|
* other non-2xx WITH `error.response`; dropping those instead of reporting them is
|
|
* what would let the breaker open in the middle of discovery.
|
|
*/
|
|
export function reportProviderRequestFailure(
|
|
guard: HostConnectivityGuard,
|
|
token: HostRequestToken | null,
|
|
error: unknown,
|
|
options: { countFailures?: boolean; requestUrl?: string } = {}
|
|
): void {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
const manualChain = error instanceof ProviderRequestError;
|
|
if (error instanceof ProviderRequestError) {
|
|
if (error.initialResponded) {
|
|
guard.reportSuccess(token);
|
|
return;
|
|
}
|
|
error = error.cause;
|
|
}
|
|
const countFailures = options.countFailures ?? true;
|
|
switch (classifyHostRequestFailure(error)) {
|
|
case 'host-level':
|
|
// Redirect attribution is checked BEFORE the exemption, not after.
|
|
// A 3xx from the guarded endpoint is an answer, and an exempt probe
|
|
// observing one has to clear the record just as it does for any
|
|
// other response — otherwise an ordinary timeout, a probe that was
|
|
// redirected to a dead destination, and another ordinary timeout
|
|
// still read as two consecutive failures.
|
|
if (
|
|
!manualChain &&
|
|
failedAfterRedirect(error, token, options.requestUrl)
|
|
) {
|
|
guard.reportSuccess(token);
|
|
break;
|
|
}
|
|
if (!countFailures) {
|
|
break;
|
|
}
|
|
guard.reportFailure(token);
|
|
break;
|
|
case 'responded':
|
|
guard.reportSuccess(token);
|
|
break;
|
|
default:
|
|
guard.reportInconclusive(token);
|
|
break;
|
|
}
|
|
}
|