diff --git a/apps/desktop/src/main/billing/entitlement.ts b/apps/desktop/src/main/billing/entitlement.ts index 79f6ca50..0d200280 100644 --- a/apps/desktop/src/main/billing/entitlement.ts +++ b/apps/desktop/src/main/billing/entitlement.ts @@ -5,12 +5,17 @@ * authenticated /api/auth/session endpoint and cache it briefly so the * streaming gate doesn't hit the network on every start. On any failure we * fail closed to 'free' (YouTube-only), never open. + * + * Failing closed only stays fair if a blip isn't mistaken for a failure, so the + * session lookup goes through apiFetch: a transient connect timeout is retried + * rather than silently downgrading a paying user for the length of the cache. */ import type { Plan, Profile } from '@pairux/shared-types'; import { effectivePlan } from '@pairux/shared-types'; import { API_BASE_URL } from '../../shared/config'; import { getValidAuth } from '../auth/secure-storage'; +import { apiFetch } from '../lib/apiFetch'; const CACHE_TTL_MS = 60_000; @@ -31,7 +36,7 @@ async function fetchPlanFromServer(): Promise { if (!stored) return 'free'; try { - const response = await fetch(`${API_BASE_URL}/api/auth/session`, { + const response = await apiFetch(`${API_BASE_URL}/api/auth/session`, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${stored.accessToken}`, diff --git a/apps/desktop/src/main/ipc/network-error.test.ts b/apps/desktop/src/main/ipc/network-error.test.ts index 673b9ec3..6c276138 100644 --- a/apps/desktop/src/main/ipc/network-error.test.ts +++ b/apps/desktop/src/main/ipc/network-error.test.ts @@ -20,4 +20,20 @@ describe('formatNetworkError', () => { expect(formatNetworkError(error)).toContain('DNS lookup failed'); expect(formatNetworkError(error)).toContain('ENOTFOUND'); }); + + // undici's connect timeout carries no hostname/syscall, only a code and a + // message, and it is the code most likely to reach a user: apiFetch retries + // it, so seeing it at all means the retries were already spent. + it('gives undici connect timeouts friendly text', () => { + const error = new Error('fetch failed') as Error & { + cause?: Record; + }; + error.cause = { + code: 'UND_ERR_CONNECT_TIMEOUT', + message: 'Connect Timeout Error (attempted address: pairux.com:443, timeout: 10000ms)', + }; + + expect(formatNetworkError(error)).toContain('Connection timed out'); + expect(formatNetworkError(error)).toContain('UND_ERR_CONNECT_TIMEOUT'); + }); }); diff --git a/apps/desktop/src/main/ipc/network-error.ts b/apps/desktop/src/main/ipc/network-error.ts index b25c5367..50134978 100644 --- a/apps/desktop/src/main/ipc/network-error.ts +++ b/apps/desktop/src/main/ipc/network-error.ts @@ -7,12 +7,21 @@ interface FetchCause { message?: string; } +// Codes apiFetch retries only reach a user once those retries are spent, so +// each one needs text worth reading. UND_ERR_HEADERS_TIMEOUT is not retried — +// the request was already sent, so a POST can't be replayed safely — but it +// surfaces the same way, so it is spelled out here too. const FRIENDLY_CODES: Record = { ENOTFOUND: 'DNS lookup failed', EAI_AGAIN: 'DNS lookup timed out', ECONNREFUSED: 'Connection refused', ECONNRESET: 'Connection reset', ETIMEDOUT: 'Connection timed out', + ENETUNREACH: 'Network unreachable', + EHOSTUNREACH: 'Host unreachable', + UND_ERR_CONNECT_TIMEOUT: 'Connection timed out', + UND_ERR_HEADERS_TIMEOUT: 'Server took too long to respond', + UND_ERR_SOCKET: 'Connection closed unexpectedly', CERT_HAS_EXPIRED: 'TLS certificate expired', DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS certificate is self-signed', UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS certificate verification failed',