From 483255dcf5ce9cc996c326dcf0914fff02f22298 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 10:09:51 +0000 Subject: [PATCH] fix(desktop): retry the plan lookup and name the timeout that reaches users Two gaps found while tracing a user-visible "fetch failed (UND_ERR_CONNECT_TIMEOUT | ...)" back to its source. The billing plan lookup called fetch directly, bypassing apiFetch and its retries, then failed closed to 'free'. Failing closed is right, but without a retry a single transient connect timeout downgrades a paying user to YouTube-only for the length of the cache. It now goes through apiFetch, so a blip is ridden out rather than billed as a failure. UND_ERR_CONNECT_TIMEOUT also had no entry in FRIENDLY_CODES, which is backwards: apiFetch retries it, so a user only ever sees it once the retries are spent, making it the code most likely to be read and the one with the least to read. The retryable codes now all carry friendly text, as does the headers timeout, which is not retried (the request was already sent) but surfaces the same way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RJFxocQAFeUPNW7oMNwe6e --- apps/desktop/src/main/billing/entitlement.ts | 7 ++++++- apps/desktop/src/main/ipc/network-error.test.ts | 16 ++++++++++++++++ apps/desktop/src/main/ipc/network-error.ts | 9 +++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) 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',