diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts index 8de03727f..288c32c8d 100644 --- a/src/shared/__tests__/api-errors.test.ts +++ b/src/shared/__tests__/api-errors.test.ts @@ -1,6 +1,7 @@ import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; import axios from 'axios'; import { analytics } from '@utils/analytics'; +import { detectRegion } from '@utils/urls'; import { diagnoseApiError, fetchProjectData, @@ -9,6 +10,11 @@ import { } from '../api'; vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); +// 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() }, })); @@ -93,6 +99,16 @@ describe('retryTransient', () => { }); describe('handleApiError', () => { + it('says the request timed out', () => { + const error = handleApiError( + networkError(AxiosError.ECONNABORTED), + 'fetch project data', + ); + + expect(error.message).toContain('fetch project data (timed out)'); + expect(error.networkCode).toBe('ECONNABORTED'); + }); + it('names the host and code when no response came back', () => { const error = handleApiError( networkError('ECONNRESET'), @@ -254,3 +270,17 @@ describe('fetchProjectData failure analytics', () => { ); }); }); + +describe('detectRegion', () => { + afterEach(() => vi.restoreAllMocks()); + + 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 ce9eb4e35..15515ef34 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -4,7 +4,7 @@ import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; import { httpsProxyConfigured } from './http-proxy'; import { checkCertificateChain, UNTRUSTED_CERT_CODES } from './tls-check'; -import { WIZARD_USER_AGENT } from './constants'; +import { LOGIN_REQUEST_TIMEOUT_MS, WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; /** @@ -167,6 +167,8 @@ export class ApiError extends Error { public readonly endpoint?: string, /** Set after a TLS check: whether the OS store would verify the host's chain. */ public readonly systemStoreTrusts?: boolean, + /** Node's error code when no response came back, such as `ECONNRESET`. */ + public readonly networkCode?: string, ) { super(message); this.name = 'ApiError'; @@ -219,14 +221,10 @@ function describeAxiosFailure(error: unknown): string { } /** Why a fetch failed, as exception properties a query can filter on. */ -function failureProperties( - error: unknown, - apiError: ApiError, -): Record { +function failureProperties(apiError: ApiError): Record { return { status_code: apiError.statusCode, - network_code: - axios.isAxiosError(error) && !error.response ? error.code : undefined, + network_code: apiError.networkCode, system_store_trusts: apiError.systemStoreTrusts, }; } @@ -242,6 +240,7 @@ export async function fetchUserData( Authorization: `Bearer ${accessToken}`, 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }), ); @@ -251,7 +250,7 @@ export async function fetchUserData( analytics.captureException(apiError, { endpoint: '/api/users/@me/', baseUrl, - ...failureProperties(error, apiError), + ...failureProperties(apiError), }); throw apiError; } @@ -312,6 +311,7 @@ export async function fetchProjectData( Authorization: `Bearer ${accessToken}`, 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }), ); @@ -322,7 +322,7 @@ export async function fetchProjectData( endpoint: `/api/projects/${projectId}/`, baseUrl, projectId, - ...failureProperties(error, apiError), + ...failureProperties(apiError), }); throw apiError; } @@ -572,12 +572,17 @@ export async function diagnoseApiError( undefined, error.config?.url, result?.systemStoreTrusts, + error.code, ); } function unreachableMessage(error: AxiosError, operation: string): string { const host = hostOf(error) ?? 'PostHog'; - 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 (isLocalHost(error)) { return `Couldn't reach ${host} to ${operation} (${code}). Check that your local server is running.`; } @@ -599,6 +604,8 @@ export function handleApiError(error: unknown, operation: string): ApiError { unreachableMessage(axiosError, operation), undefined, endpoint, + undefined, + axiosError.code, ); } 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..94690c01c 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({ + networkCode: '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 40c91d295..dd53dcdc9 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, @@ -10,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, @@ -348,7 +350,8 @@ function isPortInUseError(error: unknown): boolean { ); } -async function exchangeCodeForToken( +/** Exported for tests. */ +export async function exchangeCodeForToken( code: string, codeVerifier: string, callbackUrl: string, @@ -374,6 +377,7 @@ async function exchangeCodeForToken( 'Content-Type': 'application/json', 'User-Agent': WIZARD_USER_AGENT, }, + timeout: LOGIN_REQUEST_TIMEOUT_MS, }, ); } catch (e) { @@ -395,6 +399,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 +637,8 @@ export async function performOAuthFlow( step: 'oauth_flow', oauth_error_code: oauthErrorCode, oauth_error_description: flowError?.description, + network_code: + error instanceof ApiError ? error.networkCode : undefined, client_id: clientId, requested_scopes: config.scopes.join(' '), // Collapse OAuth callback failures of the same kind into one issue