Files
iptvnator/apps/web-backend/src/app/validated-http-client.ts
T
4grayandClaude Fable 5.1 01c423ac43 fix(portals): stop treating a slow panel as a dead host; IPv4 fallback budget in Electron (#1621)
* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process

The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): stop treating a slow panel as a dead one in the host guard

axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.

Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.

The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): let an accepted connection clear the host-failure streak

Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): credit an accepted connection when it happens, not when the request settles

Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.

Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): decide the proxy exemption with axios' own resolution

Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.

The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:50:50 +02:00

224 lines
8.5 KiB
TypeScript

import { ProviderAxiosTransport } from './provider-axios-transport';
import {
discardProviderBody,
discardProviderErrorBody,
readProviderBody,
} from './provider-response';
import axios, { AxiosRequestConfig } from 'axios';
import { Agent as HttpAgent, ClientRequest } from 'node:http';
import { Agent as HttpsAgent } from 'node:https';
import { isIP, LookupFunction } from 'node:net';
import { ProviderRequestError } from './provider-request-error';
import { ProviderUrlPolicy, validateProviderUrl } from './provider-url-policy';
export interface WebBackendHttpGetOptions {
readonly headers?: Record<string, string>;
readonly params?: Record<string, string>;
readonly responseType?: 'arraybuffer';
readonly timeout?: number;
readonly signal?: AbortSignal;
/**
* Called once a hop's TCP connection is established. The host guard uses
* it to tell a provider that never accepted the connection from one that
* accepted it and then went silent. Honoured by the transport, which owns
* the `ClientRequest` (see `ProviderAxiosTransport`).
*/
readonly onConnect?: () => void;
}
export type ProviderTransportOptions = Omit<
WebBackendHttpGetOptions,
'responseType'
> &
Pick<
AxiosRequestConfig,
| 'responseType'
| 'maxRedirects'
| 'validateStatus'
| 'httpAgent'
| 'httpsAgent'
| 'proxy'
| 'adapter'
>;
export interface WebBackendHttpResponse<T> {
readonly data: T;
readonly status: number;
readonly statusText?: string;
readonly request?: ClientRequest;
readonly headers: { readonly location?: string };
}
/** Injected transports must honor the same options and status contract as axios. */
export interface WebBackendHttpClient {
get<T>(
url: string,
options?: ProviderTransportOptions
): Promise<WebBackendHttpResponse<T>>;
}
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set([
'authorization',
'cookie',
'proxy-authorization',
'sn',
]);
export class ValidatedHttpClient implements WebBackendHttpClient {
constructor(
private readonly policy: ProviderUrlPolicy,
private readonly transport: WebBackendHttpClient = new ProviderAxiosTransport()
) {}
async get<T>(
rawUrl: string,
options: WebBackendHttpGetOptions = {}
): Promise<WebBackendHttpResponse<T>> {
let currentUrl = rawUrl;
let params = options.params;
let headers = options.headers;
let initialResponded = false;
const visited = new Set<string>();
try {
for (let redirects = 0; ; redirects++) {
options.signal?.throwIfAborted();
const target = await validateProviderUrl(
currentUrl,
this.policy
);
options.signal?.throwIfAborted();
if ('message' in target) {
throw new ProviderRequestError(
initialResponded,
target.lookupError,
target
);
}
// Match axios serialization once. Location is resolved against
// the URL actually sent; original params are never replayed.
const sentUrl = new URL(
axios.getUri({ url: target.url.href, params })
);
sentUrl.hash = '';
if (visited.has(sentUrl.href))
throw redirectError('Redirect cycle detected');
visited.add(sentUrl.href);
const lookup = pinnedLookup(target.addresses);
// Fresh agents prevent socket-pool reuse across validations.
// Empty proxyEnv also disables Node's native env proxy support.
const agentOptions = { lookup, proxyEnv: {} };
const httpAgent = new HttpAgent(agentOptions);
const httpsAgent = new HttpsAgent(agentOptions);
let response: WebBackendHttpResponse<T>;
try {
response = await this.transport.get<T>(target.url.href, {
...options,
headers,
params,
adapter: 'http',
proxy: false,
maxRedirects: 0,
responseType: 'stream',
httpAgent,
httpsAgent,
validateStatus: (status) =>
REDIRECT_STATUSES.has(status) ||
(status >= 200 && status < 300),
});
if (!REDIRECT_STATUSES.has(response.status)) {
try {
return {
...response,
data: await readProviderBody(
response.data,
options.responseType === 'arraybuffer',
options.timeout,
response.request?.socket ?? undefined
),
};
} catch (error) {
if (axios.isCancel(error)) throw error;
// A broken/stalled body still proves the endpoint
// answered. Preserve buffered axios error semantics.
throw Object.assign(
new Error('Provider response body failed'),
{
cause: error,
response: {
status: response.status,
statusText: response.statusText,
},
}
);
}
}
initialResponded = true;
discardProviderBody(response.data);
} catch (error) {
discardProviderErrorBody(error);
throw error;
} finally {
httpAgent.destroy();
httpsAgent.destroy();
}
if (redirects >= 5) throw redirectError('Too many redirects');
const location = response.headers.location;
if (!location)
throw redirectError(
'Redirect response did not include a location'
);
let nextUrl: URL;
try {
nextUrl = new URL(location, sentUrl);
} catch {
throw redirectError('Redirect location is not a valid URL');
}
if (nextUrl.origin !== sentUrl.origin) {
headers = Object.fromEntries(
Object.entries(headers ?? {}).filter(
([name]) =>
!SENSITIVE_HEADERS.has(name.toLowerCase()) &&
name.toLowerCase() !== 'host'
)
);
}
params = undefined;
currentUrl = nextUrl.href;
}
} catch (error) {
if (error instanceof ProviderRequestError) throw error;
throw new ProviderRequestError(initialResponded, error);
}
}
}
function redirectError(message: string): ProviderRequestError {
return new ProviderRequestError(true, undefined, { message, status: 502 });
}
function pinnedLookup(addresses: readonly string[]): LookupFunction {
const records = addresses.map((address) => ({
address,
family: isIP(address),
}));
return (_hostname, options, callback) => {
const eligible = options.family
? records.filter((record) => record.family === options.family)
: records;
if (!eligible.length) {
callback(
Object.assign(
new Error('No validated address for the requested family'),
{ code: 'ENOTFOUND' }
),
[]
);
} else if (options.all) {
callback(null, eligible);
} else {
callback(null, eligible[0].address, eligible[0].family);
}
};
}