From 00a6aa50ac31f350d6cf8e15b5b1e5f25b1f3011 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 29 Oct 2026 16:26:21 -0400 Subject: [PATCH 1/2] fix: name the cause when a login request gets no response Co-Authored-By: Claude Opus 5.5 --- src/shared/__tests__/api-errors.test.ts | 45 +++++++++++++++++++++++++ src/shared/api.ts | 43 ++++++++++++++++++++++- src/tui/auth/oauth.ts | 6 ++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/shared/__tests__/api-errors.test.ts diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts new file mode 100644 index 000000000..9c38dfc24 --- /dev/null +++ b/src/shared/__tests__/api-errors.test.ts @@ -0,0 +1,45 @@ +import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; +import { handleApiError } from '../api'; + +const url = 'https://us.posthog.com/api/projects/1/'; +const config = { url, headers: new AxiosHeaders() }; + +function networkError(code: string): AxiosError { + return new AxiosError('request failed', code, config, {}); +} + +describe('handleApiError', () => { + it('names TLS inspection when the chain is untrusted', () => { + const error = handleApiError( + networkError('SELF_SIGNED_CERT_IN_CHAIN'), + 'fetch project data', + ); + + expect(error.message).toContain('intercepting HTTPS to us.posthog.com'); + expect(error.message).toContain('(SELF_SIGNED_CERT_IN_CHAIN)'); + expect(error.code).toBe('SELF_SIGNED_CERT_IN_CHAIN'); + }); + + it('names the host and code when nothing answers', () => { + const error = handleApiError( + networkError('ECONNRESET'), + 'fetch project data', + ); + + expect(error.message).toBe( + "Couldn't reach us.posthog.com to fetch project data (ECONNRESET). A VPN, proxy, firewall or security tool may be blocking it. Allow *.posthog.com, then run the wizard again.", + ); + expect(error.code).toBe('ECONNRESET'); + }); + + it('includes the status when the server sent no detail', () => { + const response = { status: 502, data: {}, config } as AxiosResponse; + const error = handleApiError( + new AxiosError('bad gateway', 'ERR_BAD_RESPONSE', config, {}, response), + 'fetch project data', + ); + + expect(error.message).toBe('Failed to fetch project data (HTTP 502)'); + expect(error.statusCode).toBe(502); + }); +}); diff --git a/src/shared/api.ts b/src/shared/api.ts index 18920ef77..5f588cab4 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -162,6 +162,8 @@ export class ApiError extends Error { message: string, public readonly statusCode?: number, public readonly endpoint?: string, + /** Node's error code when no response came back, such as `ECONNRESET`. */ + public readonly code?: string, ) { super(message); this.name = 'ApiError'; @@ -186,6 +188,8 @@ export async function fetchUserData( analytics.captureException(apiError, { endpoint: '/api/users/@me/', baseUrl, + status: apiError.statusCode, + error_code: apiError.code, }); throw apiError; } @@ -254,6 +258,8 @@ export async function fetchProjectData( endpoint: `/api/projects/${projectId}/`, baseUrl, projectId, + status: apiError.statusCode, + error_code: apiError.code, }); throw apiError; } @@ -422,6 +428,31 @@ export async function fetchMarkdownNotebook( } } +/** Node's codes for a certificate chain it can't verify, as TLS inspection causes. */ +const UNTRUSTED_CERT_CODES = new Set([ + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'CERT_UNTRUSTED', +]); + +/** A request that got no response: name the host and Node's code, and who can fix it. */ +function noResponseMessage(error: AxiosError, operation: string): string { + let host = 'PostHog'; + try { + host = new URL(error.config?.url ?? '').host; + } catch { + // Keep the generic name. + } + const code = error.code ?? 'no response'; + if (UNTRUSTED_CERT_CODES.has(code)) { + return `Couldn't ${operation}: a VPN, proxy or security tool on this network is intercepting HTTPS to ${host} (${code}). Ask your IT team to allow *.posthog.com without HTTPS inspection, then run the wizard again.`; + } + return `Couldn't reach ${host} to ${operation} (${code}). A VPN, proxy, firewall or security tool may be blocking it. Allow *.posthog.com, then run the wizard again.`; +} + export function handleApiError(error: unknown, operation: string): ApiError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ detail?: string }>; @@ -429,6 +460,15 @@ export function handleApiError(error: unknown, operation: string): ApiError { const detail = axiosError.response?.data?.detail; const endpoint = axiosError.config?.url; + if (!axiosError.response) { + return new ApiError( + noResponseMessage(axiosError, operation), + undefined, + endpoint, + axiosError.code, + ); + } + if (status === 401) { return new ApiError( `Authentication failed while trying to ${operation}`, @@ -453,7 +493,8 @@ export function handleApiError(error: unknown, operation: string): ApiError { ); } - const message = detail || `Failed to ${operation}`; + const message = + detail || `Failed to ${operation} (HTTP ${axiosError.response.status})`; return new ApiError(message, status, endpoint); } diff --git a/src/tui/auth/oauth.ts b/src/tui/auth/oauth.ts index 40c91d295..623ff811a 100644 --- a/src/tui/auth/oauth.ts +++ b/src/tui/auth/oauth.ts @@ -2,6 +2,7 @@ import * as crypto from 'node:crypto'; import * as http from 'node:http'; import { execSync } from 'node:child_process'; import axios from 'axios'; +import { ApiError, handleApiError } from '@shared/api'; import { getOAuthClientId, missingOAuthScopes, @@ -395,6 +396,10 @@ async function exchangeCodeForToken( ); throw exchangeError; } + // No response: name the cause rather than axios's bare message. + if (axios.isAxiosError(e) && !e.response) { + throw handleApiError(e, 'finish logging in'); + } throw e; } @@ -629,6 +634,7 @@ export async function performOAuthFlow( step: 'oauth_flow', oauth_error_code: oauthErrorCode, oauth_error_description: flowError?.description, + error_code: error instanceof ApiError ? error.code : undefined, client_id: clientId, requested_scopes: config.scopes.join(' '), // Collapse OAuth callback failures of the same kind into one issue From f546788896ee5a31c8a3a5f1c6daf815536149d2 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 29 Oct 2026 16:40:39 -0400 Subject: [PATCH 2/2] fix: time out login requests and name local and region failures Co-Authored-By: Claude Opus 5.5 --- src/shared/__tests__/api-errors.test.ts | 57 +++++++++++++++++++++++-- src/shared/api.ts | 20 +++++++-- src/shared/constants.ts | 3 ++ src/shared/utils/urls.ts | 23 ++++++++-- src/tui/auth/__tests__/oauth.test.ts | 23 ++++++++++ src/tui/auth/oauth.ts | 5 ++- 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts index 9c38dfc24..3d22a8039 100644 --- a/src/shared/__tests__/api-errors.test.ts +++ b/src/shared/__tests__/api-errors.test.ts @@ -1,13 +1,27 @@ -import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; -import { handleApiError } from '../api'; +import axios, { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; +import { analytics } from '@utils/analytics'; +import { detectRegion } from '@utils/urls'; +import { fetchProjectData, handleApiError } from '../api'; + +// Dev builds pin localhost, which skips the us/eu probe. +vi.mock(import('@shared/constants'), async (importOriginal) => ({ + ...(await importOriginal()), + IS_DEV: false, +})); +vi.mock('@utils/analytics', () => ({ + analytics: { captureException: vi.fn() }, +})); const url = 'https://us.posthog.com/api/projects/1/'; const config = { url, headers: new AxiosHeaders() }; -function networkError(code: string): AxiosError { - return new AxiosError('request failed', code, config, {}); +function networkError(code: string, requestUrl = url): AxiosError { + const requestConfig = { url: requestUrl, headers: new AxiosHeaders() }; + return new AxiosError('request failed', code, requestConfig, {}); } +afterEach(() => vi.restoreAllMocks()); + describe('handleApiError', () => { it('names TLS inspection when the chain is untrusted', () => { const error = handleApiError( @@ -42,4 +56,39 @@ describe('handleApiError', () => { expect(error.message).toBe('Failed to fetch project data (HTTP 502)'); expect(error.statusCode).toBe(502); }); + + it('points a local host at the local server', () => { + const error = handleApiError( + networkError('ECONNREFUSED', 'http://localhost:8010/api/projects/1/'), + 'fetch project data', + ); + + expect(error.message).toBe( + "Couldn't reach localhost:8010 to fetch project data (ECONNREFUSED). Check that your local server is running.", + ); + }); +}); + +describe('login requests', () => { + it('sends the cause code with the captured exception', async () => { + vi.spyOn(axios, 'get').mockRejectedValue(networkError('ECONNRESET')); + + await expect( + fetchProjectData('token', 1, 'https://us.posthog.com'), + ).rejects.toThrow('(ECONNRESET)'); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ error_code: 'ECONNRESET' }), + ); + }); + + it('names the network cause when no region answers', async () => { + vi.spyOn(axios, 'get').mockRejectedValue( + networkError('SELF_SIGNED_CERT_IN_CHAIN'), + ); + + await expect(detectRegion('token')).rejects.toThrow( + 'intercepting HTTPS to us.posthog.com', + ); + }); }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 5f588cab4..5e92b4568 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,7 +1,7 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; -import { WIZARD_USER_AGENT } from './constants'; +import { LOGIN_REQUEST_TIMEOUT_MS, WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; /** @@ -180,6 +180,7 @@ export async function fetchUserData( Authorization: `Bearer ${accessToken}`, 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }); return ApiUserSchema.parse(response.data); @@ -249,6 +250,7 @@ export async function fetchProjectData( Authorization: `Bearer ${accessToken}`, 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }); return ApiProjectSchema.parse(response.data); @@ -439,14 +441,26 @@ const UNTRUSTED_CERT_CODES = new Set([ ]); /** A request that got no response: name the host and Node's code, and who can fix it. */ +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + function noResponseMessage(error: AxiosError, operation: string): string { let host = 'PostHog'; + let local = false; try { - host = new URL(error.config?.url ?? '').host; + const url = new URL(error.config?.url ?? ''); + host = url.host; + local = LOCAL_HOSTNAMES.has(url.hostname); } catch { // Keep the generic name. } - const code = error.code ?? 'no response'; + // Axios reports its own timeout as ECONNABORTED, which reads as a reset. + const code = + error.code === AxiosError.ECONNABORTED + ? 'timed out' + : error.code ?? 'no response'; + if (local) { + return `Couldn't reach ${host} to ${operation} (${code}). Check that your local server is running.`; + } if (UNTRUSTED_CERT_CODES.has(code)) { return `Couldn't ${operation}: a VPN, proxy or security tool on this network is intercepting HTTPS to ${host} (${code}). Ask your IT team to allow *.posthog.com without HTTPS inspection, then run the wizard again.`; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9975c0daf..9f6b324f9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -381,3 +381,6 @@ export const AGENTIC_DETECTION_RETRY_TIMEOUT_MS = 90_000; * from approval to submit the code — not how long this window may stay open. */ export const OAUTH_TIMEOUT_MS = 1_800_000; + +/** Per request on the login path, so a dropped connection fails rather than hangs. */ +export const LOGIN_REQUEST_TIMEOUT_MS = 30_000; diff --git a/src/shared/utils/urls.ts b/src/shared/utils/urls.ts index bb10a19af..8be236c27 100644 --- a/src/shared/utils/urls.ts +++ b/src/shared/utils/urls.ts @@ -1,5 +1,10 @@ import axios from 'axios'; -import { IS_DEV, WIZARD_USER_AGENT } from '@shared/constants'; +import { handleApiError } from '@shared/api'; +import { + IS_DEV, + LOGIN_REQUEST_TIMEOUT_MS, + WIZARD_USER_AGENT, +} from '@shared/constants'; import type { CloudRegion } from './types'; /** @@ -77,13 +82,25 @@ export async function detectRegion( }; const [usResult, euResult] = await Promise.allSettled([ - axios.get('https://us.posthog.com/api/users/@me/', { headers }), - axios.get('https://eu.posthog.com/api/users/@me/', { headers }), + axios.get('https://us.posthog.com/api/users/@me/', { + headers, + timeout: LOGIN_REQUEST_TIMEOUT_MS, + }), + axios.get('https://eu.posthog.com/api/users/@me/', { + headers, + timeout: LOGIN_REQUEST_TIMEOUT_MS, + }), ]); if (usResult.status === 'fulfilled') return 'us'; if (euResult.status === 'fulfilled') return 'eu'; + // Neither region answered: a network cause, not the token. + const reason: unknown = usResult.reason; + if (axios.isAxiosError(reason) && !reason.response) { + throw handleApiError(reason, 'find your PostHog region'); + } + throw new Error( 'Could not determine cloud region from access token. Please check your PostHog account.', ); diff --git a/src/tui/auth/__tests__/oauth.test.ts b/src/tui/auth/__tests__/oauth.test.ts index 03678a133..487679ee6 100644 --- a/src/tui/auth/__tests__/oauth.test.ts +++ b/src/tui/auth/__tests__/oauth.test.ts @@ -1,5 +1,7 @@ +import axios, { AxiosError, AxiosHeaders } from 'axios'; import { assertWizardCompletionScope, + exchangeCodeForToken, extractOAuthCode, isAuthorizationTimeout, } from '../oauth'; @@ -126,3 +128,24 @@ describe('wizard OAuth scopes', () => { ]); }); }); + +describe('exchangeCodeForToken', () => { + afterEach(() => vi.restoreAllMocks()); + + it('names the cause when the token endpoint gives no response', async () => { + const config = { + url: 'https://oauth.posthog.com/oauth/token', + headers: new AxiosHeaders(), + }; + vi.spyOn(axios, 'post').mockRejectedValue( + new AxiosError('failed', 'SELF_SIGNED_CERT_IN_CHAIN', config, {}), + ); + + await expect( + exchangeCodeForToken('code', 'verifier', 'http://localhost:8239'), + ).rejects.toMatchObject({ + code: 'SELF_SIGNED_CERT_IN_CHAIN', + message: expect.stringContaining('intercepting HTTPS') as string, + }); + }); +}); diff --git a/src/tui/auth/oauth.ts b/src/tui/auth/oauth.ts index 623ff811a..839eaa39b 100644 --- a/src/tui/auth/oauth.ts +++ b/src/tui/auth/oauth.ts @@ -11,6 +11,7 @@ import { } from '@programs'; import type { OAuthTokenResponse } from '@programs/types'; import { + LOGIN_REQUEST_TIMEOUT_MS, OAUTH_PORTS, OAUTH_TIMEOUT_MS, WIZARD_USER_AGENT, @@ -349,7 +350,8 @@ function isPortInUseError(error: unknown): boolean { ); } -async function exchangeCodeForToken( +/** Exported for tests. */ +export async function exchangeCodeForToken( code: string, codeVerifier: string, callbackUrl: string, @@ -375,6 +377,7 @@ async function exchangeCodeForToken( 'Content-Type': 'application/json', 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }, ); } catch (e) {