mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* fix(pwa): tolerate broken IPv6 routes and surface provider error codes Node's happy-eyeballs racing gives each address attempt only 250 ms, so a dual-stack provider hostname behind an IPv4-only VPN namespace (Gluetun, WireGuard) exhausts every attempt and the web backend answered with a bare 502. Raise the per-attempt budget to 2500 ms at startup — keeping the IPv6->IPv4 fallback automatic — while an explicit --network-family-autoselection-attempt-timeout from NODE_OPTIONS still wins. Provider proxy failures now log the target hostname plus the underlying Node error codes (never the URL query, which carries credentials) and return the primary code in the error body, so the app shows "Bad Gateway (ETIMEDOUT)" instead of an unexplained 502. Closes #1400 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): surface proxy network codes in import/refresh toasts, document runtime contract Codex: /parse connection failures arrive as HTTP 500 whose body carries the new code field, but fetchFromUrl()/refreshPlaylist() mapped only the HTTP status, so the toast stayed generic. Append the code to the translated message (regression-covered for both flows). Greptile: record the web-backend happy-eyeballs/diagnostics runtime contract in CLAUDE.md's monorepo structure section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): drop provider reason phrases from logs, honor underscore flag spellings Codex round 2: the HTTP reason phrase is provider-controlled and can echo the credential-bearing request URL, so the failure log now carries only the numeric status; and Node treats underscores and dashes interchangeably in flag names, so the explicit-override check normalizes spelling before matching (verified live: --network_family_autoselection_attempt_timeout applies in both CLI and NODE_OPTIONS forms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): match the timeout flag as a complete NODE_OPTIONS token Codex round 3 (P3): a raw substring search also fired on the flag text embedded in another option's value, silently skipping the 2500 ms default. Tokenize NODE_OPTIONS on whitespace and match the normalized option name exactly or with '='. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
import {
|
|
collectProviderErrorCodes,
|
|
logProviderRequestFailure,
|
|
normalizeProviderError,
|
|
} from './provider-error';
|
|
|
|
function errorWithCode(message: string, code: string): Error {
|
|
return Object.assign(new Error(message), { code });
|
|
}
|
|
|
|
describe('collectProviderErrorCodes', () => {
|
|
it('collects the aggregate code and the per-address member codes', () => {
|
|
const aggregate = Object.assign(
|
|
new AggregateError([
|
|
errorWithCode('connect ETIMEDOUT 104.21.0.1:80', 'ETIMEDOUT'),
|
|
errorWithCode(
|
|
'connect ENETUNREACH 2606:4700::1:80',
|
|
'ENETUNREACH'
|
|
),
|
|
]),
|
|
{ code: 'ETIMEDOUT' }
|
|
);
|
|
|
|
expect(collectProviderErrorCodes(aggregate)).toEqual([
|
|
'ETIMEDOUT',
|
|
'ENETUNREACH',
|
|
]);
|
|
});
|
|
|
|
it('walks the cause chain', () => {
|
|
const wrapped = new Error('request failed', {
|
|
cause: errorWithCode('getaddrinfo ENOTFOUND host', 'ENOTFOUND'),
|
|
});
|
|
|
|
expect(collectProviderErrorCodes(wrapped)).toEqual(['ENOTFOUND']);
|
|
});
|
|
|
|
it('survives cyclic error graphs', () => {
|
|
const first = errorWithCode('first', 'ECONNRESET') as Error & {
|
|
cause?: unknown;
|
|
};
|
|
const second = new Error('second', { cause: first });
|
|
first.cause = second;
|
|
|
|
expect(collectProviderErrorCodes(second)).toEqual(['ECONNRESET']);
|
|
});
|
|
|
|
it('ignores non-string and empty codes', () => {
|
|
expect(
|
|
collectProviderErrorCodes(
|
|
Object.assign(new Error('numeric'), { code: 502 })
|
|
)
|
|
).toEqual([]);
|
|
expect(
|
|
collectProviderErrorCodes(
|
|
Object.assign(new Error('empty'), { code: '' })
|
|
)
|
|
).toEqual([]);
|
|
expect(collectProviderErrorCodes(undefined)).toEqual([]);
|
|
expect(collectProviderErrorCodes('ETIMEDOUT')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeProviderError', () => {
|
|
it('keeps the HTTP response status and statusText untouched', () => {
|
|
const httpError = Object.assign(new Error('Forbidden'), {
|
|
code: 'ERR_BAD_REQUEST',
|
|
response: { status: 403, statusText: 'Forbidden' },
|
|
});
|
|
|
|
expect(normalizeProviderError(httpError)).toEqual({
|
|
message: 'Forbidden',
|
|
status: 403,
|
|
});
|
|
});
|
|
|
|
it('keeps a statusText-only response on the HTTP branch', () => {
|
|
const partial = Object.assign(new Error('partial'), {
|
|
response: { statusText: 'Gateway Timeout' },
|
|
});
|
|
|
|
expect(normalizeProviderError(partial)).toEqual({
|
|
message: 'Gateway Timeout',
|
|
status: 502,
|
|
});
|
|
});
|
|
|
|
it('names the network code in message and body for non-HTTP failures', () => {
|
|
expect(
|
|
normalizeProviderError(errorWithCode('timeout', 'ETIMEDOUT'))
|
|
).toEqual({
|
|
message: 'Bad Gateway (ETIMEDOUT)',
|
|
status: 502,
|
|
code: 'ETIMEDOUT',
|
|
});
|
|
});
|
|
|
|
it('falls back to a bare bad gateway when no code exists', () => {
|
|
expect(normalizeProviderError(new Error('boom'))).toEqual({
|
|
message: 'Bad Gateway',
|
|
status: 502,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('logProviderRequestFailure', () => {
|
|
it('logs hostname and codes but never the URL query string', () => {
|
|
const logger = jest.fn();
|
|
|
|
logProviderRequestFailure({
|
|
error: errorWithCode('timeout', 'ETIMEDOUT'),
|
|
route: '/xtream',
|
|
url: 'http://provider.example/player_api.php?username=user&password=pass',
|
|
logger,
|
|
});
|
|
|
|
expect(logger).toHaveBeenCalledTimes(1);
|
|
const line = logger.mock.calls[0][0] as string;
|
|
expect(line).toContain('/xtream');
|
|
expect(line).toContain('provider.example');
|
|
expect(line).toContain('ETIMEDOUT');
|
|
expect(line).not.toContain('username');
|
|
expect(line).not.toContain('password');
|
|
expect(line).not.toContain('player_api.php');
|
|
});
|
|
|
|
it('describes HTTP failures by numeric status only — the reason phrase is provider-controlled', () => {
|
|
const logger = jest.fn();
|
|
|
|
logProviderRequestFailure({
|
|
error: Object.assign(new Error('Unauthorized'), {
|
|
response: {
|
|
status: 401,
|
|
statusText:
|
|
'Unauthorized username=alice&password=secret-pass',
|
|
},
|
|
}),
|
|
route: '/stalker',
|
|
url: new URL('http://portal.example/portal.php'),
|
|
logger,
|
|
});
|
|
|
|
const line = logger.mock.calls[0][0] as string;
|
|
expect(line).toContain('HTTP 401');
|
|
expect(line).not.toContain('alice');
|
|
expect(line).not.toContain('secret-pass');
|
|
});
|
|
|
|
it('logs a generic HTTP error for a statusText-only response', () => {
|
|
const logger = jest.fn();
|
|
|
|
logProviderRequestFailure({
|
|
error: Object.assign(new Error('partial'), {
|
|
response: { statusText: 'Gateway Timeout' },
|
|
}),
|
|
route: '/stalker',
|
|
url: new URL('http://portal.example/portal.php'),
|
|
logger,
|
|
});
|
|
|
|
const line = logger.mock.calls[0][0] as string;
|
|
expect(line).toContain('HTTP error');
|
|
expect(line).not.toContain('Gateway Timeout');
|
|
});
|
|
|
|
it('tolerates unparseable URLs and codeless errors', () => {
|
|
const logger = jest.fn();
|
|
|
|
logProviderRequestFailure({
|
|
error: new Error('boom'),
|
|
route: '/parse',
|
|
url: 'not a url',
|
|
logger,
|
|
});
|
|
|
|
const line = logger.mock.calls[0][0] as string;
|
|
expect(line).toContain('<invalid url>');
|
|
expect(line).toContain('unknown network error');
|
|
});
|
|
});
|