mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
@@ -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} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user