Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/shared/__tests__/api-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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() },
}));
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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',
);
});
});
27 changes: 17 additions & 10 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> {
function failureProperties(apiError: ApiError): Record<string, unknown> {
return {
status_code: apiError.statusCode,
network_code:
axios.isAxiosError(error) && !error.response ? error.code : undefined,
network_code: apiError.networkCode,
system_store_trusts: apiError.systemStoreTrusts,
};
}
Expand All @@ -242,6 +240,7 @@ export async function fetchUserData(
Authorization: `Bearer ${accessToken}`,
'User-Agent': WIZARD_USER_AGENT,
},
timeout: LOGIN_REQUEST_TIMEOUT_MS,
}),
);

Expand All @@ -251,7 +250,7 @@ export async function fetchUserData(
analytics.captureException(apiError, {
endpoint: '/api/users/@me/',
baseUrl,
...failureProperties(error, apiError),
...failureProperties(apiError),
});
throw apiError;
}
Expand Down Expand Up @@ -312,6 +311,7 @@ export async function fetchProjectData(
Authorization: `Bearer ${accessToken}`,
'User-Agent': WIZARD_USER_AGENT,
},
timeout: LOGIN_REQUEST_TIMEOUT_MS,
}),
);

Expand All @@ -322,7 +322,7 @@ export async function fetchProjectData(
endpoint: `/api/projects/${projectId}/`,
baseUrl,
projectId,
...failureProperties(error, apiError),
...failureProperties(apiError),
});
throw apiError;
}
Expand Down Expand Up @@ -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.`;
}
Expand All @@ -599,6 +604,8 @@ export function handleApiError(error: unknown, operation: string): ApiError {
unreachableMessage(axiosError, operation),
undefined,
endpoint,
undefined,
axiosError.code,
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
23 changes: 20 additions & 3 deletions src/shared/utils/urls.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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.',
);
Expand Down
23 changes: 23 additions & 0 deletions src/tui/auth/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import axios, { AxiosError, AxiosHeaders } from 'axios';
import {
assertWizardCompletionScope,
exchangeCodeForToken,
extractOAuthCode,
isAuthorizationTimeout,
} from '../oauth';
Expand Down Expand Up @@ -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,
});
});
});
12 changes: 11 additions & 1 deletion src/tui/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -374,6 +377,7 @@ async function exchangeCodeForToken(
'Content-Type': 'application/json',
'User-Agent': WIZARD_USER_AGENT,
},
timeout: LOGIN_REQUEST_TIMEOUT_MS,
},
);
} catch (e) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Loading