fix(onboarding): bound preflight probe + recover from connectivity failure (#1017)

`PreflightStep` called `axios.get` against `api.anthropic.com` and
`platform.claude.com` with no timeout, then ran `process.exit(1)` on
any failure. Users whose networks could not reach Anthropic — country
blocks, corporate firewalls, captive portals — sat indefinitely on
the "Checking connectivity..." spinner, and the rare ones who did get
a clean error were hard-killed instead of being allowed to finish
onboarding. Both modes blocked first-time setup even for users who
only intended to configure a third-party provider.

Add a 5s timeout to the probe and replace the exit with a 4s
error-display hold that advances onboarding via the normal
`onSuccess` callback. Third-party providers don't need Anthropic to
be reachable, so failed connectivity is no longer fatal.

Regression test covers: probe carries the bounded timeout option,
ECONNABORTED-style rejection returns a structured failure instead
of throwing or hanging, and a clean 200 path still reports success.

Fixes #1017
This commit is contained in:
gnanam1990
2026-05-15 07:43:32 +05:30
parent 6174d75e98
commit 91181dc8f7
2 changed files with 236 additions and 121 deletions
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
// MACRO is normally substituted at build time. The test runs without the
// bundler, so stub the build-time globals before importing the module under
// test (which transitively imports utils/http.ts → MACRO.VERSION).
;(globalThis as unknown as { MACRO?: unknown }).MACRO ??= {
VERSION: '0.0.0-test',
DISPLAY_VERSION: '0.0.0-test',
BUILD_TIME: 'test',
ISSUES_EXPLAINER: '',
PACKAGE_URL: '',
NATIVE_PACKAGE_URL: undefined,
}
describe('checkEndpoints (preflight)', () => {
afterEach(() => {
mock.restore()
})
test('passes a bounded timeout to axios so a hung probe cannot freeze onboarding (#1017)', async () => {
const calls: Array<{ url: string; options: { timeout?: number } }> = []
mock.module('axios', () => ({
default: {
get: async (
url: string,
options: { timeout?: number } = {},
): Promise<{ status: number }> => {
calls.push({ url, options })
return { status: 200 }
},
isAxiosError: () => false,
},
}))
const { checkEndpoints, PREFLIGHT_REQUEST_TIMEOUT_MS } = await import(
'./preflightChecks.js'
)
const result = await checkEndpoints()
expect(result.success).toBe(true)
expect(calls.length).toBeGreaterThan(0)
for (const call of calls) {
expect(call.options.timeout).toBe(PREFLIGHT_REQUEST_TIMEOUT_MS)
}
expect(PREFLIGHT_REQUEST_TIMEOUT_MS).toBeGreaterThan(0)
expect(PREFLIGHT_REQUEST_TIMEOUT_MS).toBeLessThanOrEqual(15_000)
})
test('returns a failure result (instead of throwing or hanging) when axios rejects with ECONNABORTED', async () => {
mock.module('axios', () => ({
default: {
get: async (): Promise<never> => {
const err = new Error('timeout of 5000ms exceeded') as Error & {
code?: string
}
err.code = 'ECONNABORTED'
throw err
},
isAxiosError: () => false,
},
}))
const { checkEndpoints } = await import('./preflightChecks.js')
const result = await checkEndpoints()
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
expect(result.error).toContain('Failed to connect to')
})
test('returns success when all probes return 200', async () => {
mock.module('axios', () => ({
default: {
get: async (): Promise<{ status: number }> => ({ status: 200 }),
isAxiosError: () => false,
},
}))
const { checkEndpoints } = await import('./preflightChecks.js')
const result = await checkEndpoints()
expect(result.success).toBe(true)
expect(result.error).toBeUndefined()
})
})
+149 -121
View File
@@ -1,151 +1,179 @@
import { c as _c } from "react-compiler-runtime";
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { Spinner } from '../components/Spinner.js';
import { getOauthConfig } from '../constants/oauth.js';
import { useTimeout } from '../hooks/useTimeout.js';
import { Box, Text } from '../ink.js';
import { getSSLErrorHint } from '../services/api/errorUtils.js';
import { getUserAgent } from './http.js';
import { logError } from './log.js';
import { getAPIProvider } from './model/providers.js';
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { logEvent } from 'src/services/analytics/index.js'
import { Spinner } from '../components/Spinner.js'
import { getOauthConfig } from '../constants/oauth.js'
import { useTimeout } from '../hooks/useTimeout.js'
import { Box, Text } from '../ink.js'
import { getSSLErrorHint } from '../services/api/errorUtils.js'
import { getUserAgent } from './http.js'
import { logError } from './log.js'
import { getAPIProvider } from './model/providers.js'
// Bound the connectivity probe so users on networks that can't reach
// api.anthropic.com (country blocks, corporate firewalls, captive portals)
// don't sit forever on the onboarding spinner. See #1017.
export const PREFLIGHT_REQUEST_TIMEOUT_MS = 5000
// Brief hold after a failed probe so the user can read the error before the
// onboarding flow advances. Failed connectivity does not block onboarding —
// third-party providers (Ollama, OpenAI, etc.) are configured later via
// /provider and don't need api.anthropic.com to be reachable.
export const PREFLIGHT_ERROR_HOLD_MS = 4000
export interface PreflightCheckResult {
success: boolean;
error?: string;
sslHint?: string;
success: boolean
error?: string
sslHint?: string
}
async function checkEndpoints(): Promise<PreflightCheckResult> {
export async function checkEndpoints(): Promise<PreflightCheckResult> {
try {
const oauthConfig = getOauthConfig();
const tokenUrl = new URL(oauthConfig.TOKEN_URL);
const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`];
const checkEndpoint = async (url: string): Promise<PreflightCheckResult> => {
const oauthConfig = getOauthConfig()
const tokenUrl = new URL(oauthConfig.TOKEN_URL)
const endpoints = [
`${oauthConfig.BASE_API_URL}/api/hello`,
`${tokenUrl.origin}/v1/oauth/hello`,
]
const checkEndpoint = async (
url: string,
): Promise<PreflightCheckResult> => {
try {
const response = await axios.get(url, {
headers: {
'User-Agent': getUserAgent()
}
});
headers: { 'User-Agent': getUserAgent() },
timeout: PREFLIGHT_REQUEST_TIMEOUT_MS,
})
if (response.status !== 200) {
const hostname = new URL(url).hostname;
const hostname = new URL(url).hostname
return {
success: false,
error: `Failed to connect to ${hostname}: Status ${response.status}`
};
error: `Failed to connect to ${hostname}: Status ${response.status}`,
}
}
return {
success: true
};
return { success: true }
} catch (error) {
const hostname = new URL(url).hostname;
const sslHint = getSSLErrorHint(error);
const hostname = new URL(url).hostname
const sslHint = getSSLErrorHint(error)
return {
success: false,
error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
sslHint: sslHint ?? undefined
};
sslHint: sslHint ?? undefined,
}
}
};
const results = await Promise.all(endpoints.map(checkEndpoint));
const failedResult = results.find(result => !result.success);
}
const results = await Promise.all(endpoints.map(checkEndpoint))
const failedResult = results.find(result => !result.success)
if (failedResult) {
// Log failure to Statsig
logEvent('tengu_preflight_check_failed', {
isConnectivityError: false,
hasErrorMessage: !!failedResult.error,
isSSLError: !!failedResult.sslHint
});
isSSLError: !!failedResult.sslHint,
})
}
return failedResult || {
success: true
};
} catch (error) {
logError(error as Error);
// Log to Statsig
return failedResult || { success: true }
} catch (error) {
logError(error as Error)
logEvent('tengu_preflight_check_failed', {
isConnectivityError: true
});
isConnectivityError: true,
})
return {
success: false,
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`
};
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
}
}
}
interface PreflightStepProps {
onSuccess: () => void;
onSuccess: () => void
}
export function PreflightStep(t0) {
const $ = _c(12);
const {
onSuccess
} = t0;
const [result, setResult] = useState(null);
const [isChecking, setIsChecking] = useState(true);
const showSpinner = useTimeout(1000) && isChecking;
let t1;
let t2;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
const run = async function run() {
const checkResult = await checkEndpoints();
setResult(checkResult);
setIsChecking(false);
};
run();
};
t2 = [];
$[0] = t1;
$[1] = t2;
} else {
t1 = $[0];
t2 = $[1];
export function PreflightStep({
onSuccess,
}: PreflightStepProps): React.ReactNode {
const [result, setResult] = useState<PreflightCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(true)
// delay showing the check since it's so fast that we normally
// want to just immediately show the next step without a flash
const showSpinner = useTimeout(1000) && isChecking
useEffect(() => {
async function run() {
const checkResult = await checkEndpoints()
setResult(checkResult)
setIsChecking(false)
}
void run()
}, [])
useEffect(() => {
if (!result) return
if (result.success) {
onSuccess()
return
}
// Connectivity failure used to call process.exit(1), which left users on
// networks that couldn't reach Anthropic — including those who only ever
// intended to use a third-party provider — unable to complete onboarding.
// Surface the error briefly, then advance so the rest of onboarding (theme,
// security notes) can finish and the user reaches a prompt where they can
// run /provider.
const timer = setTimeout(onSuccess, PREFLIGHT_ERROR_HOLD_MS)
return () => clearTimeout(timer)
}, [result, onSuccess])
if (isChecking && showSpinner) {
return (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Box paddingLeft={1}>
<Spinner />
<Text>Checking connectivity...</Text>
</Box>
</Box>
)
}
useEffect(t1, t2);
let t3;
let t4;
if ($[2] !== onSuccess || $[3] !== result) {
t3 = () => {
if (result?.success) {
onSuccess();
} else {
if (result && !result.success) {
const timer = setTimeout(_temp, 100);
return () => clearTimeout(timer);
}
}
};
t4 = [result, onSuccess];
$[2] = onSuccess;
$[3] = result;
$[4] = t3;
$[5] = t4;
} else {
t3 = $[4];
t4 = $[5];
if (!isChecking && result && !result.success) {
return (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Box flexDirection="column" gap={1}>
<Text color="error">Unable to connect to Anthropic services</Text>
<Text color="error">{result.error}</Text>
{result.sslHint ? (
<Box flexDirection="column" gap={1}>
<Text>{result.sslHint}</Text>
<Text color="suggestion">
See https://code.claude.com/docs/en/network-config
</Text>
</Box>
) : (
<Box flexDirection="column" gap={1}>
<Text>
Please check your internet connection and network settings.
</Text>
{getAPIProvider() === 'firstParty' && (
<Text>
Note: Claude Code might not be available in your country.
Check supported countries at{' '}
<Text color="suggestion">
https://anthropic.com/supported-countries
</Text>
</Text>
)}
</Box>
)}
<Text dimColor>
Continuing you can configure a non-Anthropic provider later with
/provider.
</Text>
</Box>
</Box>
)
}
useEffect(t3, t4);
let t5;
if ($[6] !== isChecking || $[7] !== result || $[8] !== showSpinner) {
t5 = isChecking && showSpinner ? <Box paddingLeft={1}><Spinner /><Text>Checking connectivity...</Text></Box> : !result?.success && !isChecking && <Box flexDirection="column" gap={1}><Text color="error">Unable to connect to Anthropic services</Text><Text color="error">{result?.error}</Text>{result?.sslHint ? <Box flexDirection="column" gap={1}><Text>{result.sslHint}</Text><Text color="suggestion">See https://code.claude.com/docs/en/network-config</Text></Box> : <Box flexDirection="column" gap={1}><Text>Please check your internet connection and network settings.</Text>{getAPIProvider() === 'firstParty' && <Text>Note: Claude Code might not be available in your country. Check supported countries at{" "}<Text color="suggestion">https://anthropic.com/supported-countries</Text></Text>}</Box>}</Box>;
$[6] = isChecking;
$[7] = result;
$[8] = showSpinner;
$[9] = t5;
} else {
t5 = $[9];
}
let t6;
if ($[10] !== t5) {
t6 = <Box flexDirection="column" gap={1} paddingLeft={1}>{t5}</Box>;
$[10] = t5;
$[11] = t6;
} else {
t6 = $[11];
}
return t6;
}
function _temp() {
return process.exit(1);
return <Box flexDirection="column" gap={1} paddingLeft={1} />
}