Skip to content

Commit faab965

Browse files
committed
fix: distinguish auth and network validation failures
1 parent 98b9bec commit faab965

4 files changed

Lines changed: 285 additions & 16 deletions

File tree

src/commands/auth/login.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,37 @@ interface QuotaApiResponse {
2020
model_remains: QuotaModelRemain[];
2121
}
2222

23+
function isNetworkUnavailable(error: unknown): boolean {
24+
if (error instanceof CLIError) {
25+
return error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT;
26+
}
27+
28+
if (!(error instanceof Error)) return false;
29+
30+
if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
31+
32+
const message = error.message.toLowerCase();
33+
const code = 'code' in error ? String(error.code).toLowerCase() : '';
34+
return (error instanceof TypeError && message === 'fetch failed')
35+
|| message.includes('failed to fetch')
36+
|| message.includes('unable to connect')
37+
|| message.includes('connection refused')
38+
|| message.includes('econnrefused')
39+
|| message.includes('connection reset')
40+
|| message.includes('econnreset')
41+
|| message.includes('network error')
42+
|| message.includes('enotfound')
43+
|| message.includes('getaddrinfo')
44+
|| message.includes('proxy')
45+
|| message.includes('socket')
46+
|| message.includes('etimedout')
47+
|| code === 'connectionrefused'
48+
|| code === 'econnrefused'
49+
|| code === 'econnreset'
50+
|| code === 'enotfound'
51+
|| code === 'etimedout';
52+
}
53+
2354
async function showQuotaAfterLogin(config: Config): Promise<void> {
2455
try {
2556
const url = quotaEndpoint(config.baseUrl);
@@ -149,11 +180,23 @@ async function loginWithApiKey(
149180
// Verify the selected region actually authorizes the quota endpoint.
150181
try {
151182
await requestJson<QuotaApiResponse>(cfg, { url: quotaEndpoint(cfg.baseUrl) });
152-
} catch {
153-
throw new CLIError(
154-
'API key validation failed.',
155-
ExitCode.AUTH,
156-
'Check that your key is valid and belongs to a Token Plan.',
183+
} catch (error) {
184+
if (error instanceof CLIError && error.exitCode === ExitCode.AUTH) {
185+
throw new CLIError(
186+
'API key validation failed.',
187+
ExitCode.AUTH,
188+
'Check that your key is valid and belongs to a Token Plan.',
189+
);
190+
}
191+
192+
if (!explicitRegion) throw error;
193+
194+
const validationFailure = isNetworkUnavailable(error)
195+
? `the ${detected} API endpoint is unreachable`
196+
: `the ${detected} API endpoint returned an inconclusive response`;
197+
process.stderr.write(
198+
`Warning: Could not validate the API key because ${validationFailure}.\n` +
199+
`Saving the explicitly selected region "${detected}". Requests may fail until validation succeeds.\n`,
157200
);
158201
}
159202

src/config/detect-region.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ExitCode } from "../errors/codes";
55

66
const QUOTA_PATH = "/v1/token_plan/remains";
77

8+
type ProbeResult = "authorized" | "unauthorized" | "unreachable" | "inconclusive";
9+
810
function quotaUrl(region: Region): string {
911
return REGIONS[region] + QUOTA_PATH;
1012
}
@@ -13,7 +15,7 @@ async function probeRegion(
1315
region: Region,
1416
apiKey: string,
1517
timeoutMs: number,
16-
): Promise<boolean> {
18+
): Promise<ProbeResult> {
1719
// MiniMax endpoints accept either Bearer or x-api-key auth — try both.
1820
// Some API key types only work with one style; trying both prevents false
1921
// negatives that would cause the wrong region to be selected, leading to
@@ -23,22 +25,41 @@ async function probeRegion(
2325
{ "x-api-key": apiKey },
2426
];
2527

28+
let sawNetworkFailure = false;
29+
let sawInconclusiveResponse = false;
30+
2631
for (const authHeader of authHeaders) {
32+
let res: Response;
2733
try {
28-
const res = await fetch(quotaUrl(region), {
34+
res = await fetch(quotaUrl(region), {
2935
headers: { ...authHeader, "Content-Type": "application/json" },
3036
signal: AbortSignal.timeout(timeoutMs),
3137
});
32-
if (!res.ok) continue;
38+
} catch {
39+
sawNetworkFailure = true;
40+
continue;
41+
}
42+
43+
if (res.status === 401 || res.status === 403) continue;
44+
if (!res.ok) {
45+
sawInconclusiveResponse = true;
46+
continue;
47+
}
48+
49+
try {
3350
const data = (await res.json()) as {
3451
base_resp?: { status_code?: number };
3552
};
36-
if (data.base_resp?.status_code === 0) return true;
53+
if (data.base_resp?.status_code === 0) return "authorized";
54+
sawInconclusiveResponse = true;
3755
} catch {
38-
// Try next auth style before giving up on this region
56+
sawInconclusiveResponse = true;
3957
}
4058
}
41-
return false;
59+
60+
if (sawInconclusiveResponse) return "inconclusive";
61+
if (sawNetworkFailure) return "unreachable";
62+
return "unauthorized";
4263
}
4364

4465
export async function detectRegion(apiKey: string): Promise<Region> {
@@ -47,16 +68,33 @@ export async function detectRegion(apiKey: string): Promise<Region> {
4768
const results = await Promise.all(
4869
regions.map(async (r) => ({
4970
region: r,
50-
ok: await probeRegion(r, apiKey, 5000),
71+
result: await probeRegion(r, apiKey, 5000),
5172
})),
5273
);
53-
const match = results.find((r) => r.ok);
74+
const match = results.find((r) => r.result === "authorized");
5475
if (!match) {
5576
process.stderr.write(" failed\n");
77+
78+
if (results.every((r) => r.result === "unauthorized")) {
79+
throw new CLIError(
80+
"API key was rejected by all regions.",
81+
ExitCode.AUTH,
82+
"No credentials were changed. Check that the key is valid and belongs to a Token Plan.",
83+
);
84+
}
85+
86+
if (results.some((r) => r.result === "unreachable")) {
87+
throw new CLIError(
88+
"Could not reach the regional API endpoints.",
89+
ExitCode.NETWORK,
90+
"No region was changed. Check the network or proxy, then retry or pass --region global|cn explicitly.",
91+
);
92+
}
93+
5694
throw new CLIError(
5795
"Could not determine the API key region.",
58-
ExitCode.AUTH,
59-
"No region was changed. Check the key and network, then retry or pass --region global|cn explicitly.",
96+
ExitCode.GENERAL,
97+
"No region was changed because the API returned an inconclusive response. Retry later or pass --region global|cn explicitly.",
6098
);
6199
}
62100
const detected: Region = match.region;

test/auth/timeout-fix.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,37 @@ describe('detect-region: probeRegion auth style fallback', () => {
9898
throw new Error('Expected region detection to fail');
9999
} catch (error) {
100100
expect(error).toBeInstanceOf(CLIError);
101-
expect((error as CLIError).message).toBe('Could not determine the API key region.');
101+
expect((error as CLIError).message).toBe('API key was rejected by all regions.');
102102
expect((error as CLIError).exitCode).toBe(ExitCode.AUTH);
103103
}
104104
} finally {
105105
(REGIONS as Record<string, string>).global = origGlobal;
106106
(REGIONS as Record<string, string>).cn = origCn;
107107
}
108108
});
109+
110+
it('reports a network error when no regional endpoint is reachable', async () => {
111+
const { REGIONS } = await import('../../src/config/schema');
112+
const origGlobal = REGIONS.global;
113+
const origCn = REGIONS.cn;
114+
(REGIONS as Record<string, string>).global = 'http://127.0.0.1:1';
115+
(REGIONS as Record<string, string>).cn = 'http://127.0.0.1:1';
116+
117+
try {
118+
const { detectRegion } = await import('../../src/config/detect-region');
119+
try {
120+
await detectRegion('unverifiable-key');
121+
throw new Error('Expected region detection to fail');
122+
} catch (error) {
123+
expect(error).toBeInstanceOf(CLIError);
124+
expect((error as CLIError).message).toBe('Could not reach the regional API endpoints.');
125+
expect((error as CLIError).exitCode).toBe(ExitCode.NETWORK);
126+
}
127+
} finally {
128+
(REGIONS as Record<string, string>).global = origGlobal;
129+
(REGIONS as Record<string, string>).cn = origCn;
130+
}
131+
});
109132
});
110133

111134
// ---------------------------------------------------------------------------

test/commands/auth/login.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { default as loginCommand } from '../../../src/commands/auth/login';
6+
import { readConfigFile, writeConfigFile } from '../../../src/config/loader';
67
import { REGIONS } from '../../../src/config/schema';
8+
import { CLIError } from '../../../src/errors/base';
9+
import { ExitCode } from '../../../src/errors/codes';
710
import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server';
811

912
describe('auth login command', () => {
@@ -106,6 +109,168 @@ describe('auth login command', () => {
106109
);
107110

108111
expect(quotaRequests).toBe(2);
112+
expect(readConfigFile()).toMatchObject({
113+
api_key: 'cn-test-key',
114+
region: 'cn',
115+
});
116+
} finally {
117+
(REGIONS as Record<string, string>).global = originalGlobal;
118+
(REGIONS as Record<string, string>).cn = originalCn;
119+
}
120+
});
121+
122+
it('does not save an explicitly selected region when authentication is rejected', async () => {
123+
server = createMockServer({
124+
routes: {
125+
'/v1/token_plan/remains': () => jsonResponse({ error: 'unauthorized' }, 401),
126+
},
127+
});
128+
129+
configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-'));
130+
process.env.MMX_CONFIG_DIR = configDir;
131+
await writeConfigFile({ api_key: 'existing-key', region: 'global' });
132+
133+
const originalCn = REGIONS.cn;
134+
(REGIONS as Record<string, string>).cn = server.url;
135+
136+
try {
137+
try {
138+
await loginCommand.execute(
139+
{
140+
region: 'global',
141+
baseUrl: REGIONS.global,
142+
output: 'text',
143+
timeout: 1,
144+
verbose: false,
145+
quiet: true,
146+
noColor: true,
147+
yes: false,
148+
dryRun: false,
149+
nonInteractive: true,
150+
async: false,
151+
},
152+
{
153+
apiKey: 'rejected-key',
154+
region: 'cn',
155+
quiet: true,
156+
verbose: false,
157+
noColor: true,
158+
yes: false,
159+
dryRun: false,
160+
help: false,
161+
nonInteractive: true,
162+
async: false,
163+
},
164+
);
165+
throw new Error('Expected API key validation to fail');
166+
} catch (error) {
167+
expect(error).toBeInstanceOf(CLIError);
168+
expect((error as CLIError).exitCode).toBe(ExitCode.AUTH);
169+
}
170+
171+
expect(readConfigFile()).toEqual({ api_key: 'existing-key', region: 'global' });
172+
} finally {
173+
(REGIONS as Record<string, string>).cn = originalCn;
174+
}
175+
});
176+
177+
it('warns and saves an explicit region when its endpoint is unreachable', async () => {
178+
configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-'));
179+
process.env.MMX_CONFIG_DIR = configDir;
180+
await writeConfigFile({ api_key: 'existing-key', region: 'global' });
181+
182+
const originalCn = REGIONS.cn;
183+
(REGIONS as Record<string, string>).cn = 'http://127.0.0.1:1';
184+
const originalWrite = process.stderr.write.bind(process.stderr);
185+
let stderr = '';
186+
(process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => {
187+
stderr += String(chunk);
188+
return true;
189+
};
190+
191+
try {
192+
await loginCommand.execute(
193+
{
194+
region: 'global',
195+
baseUrl: REGIONS.global,
196+
output: 'text',
197+
timeout: 1,
198+
verbose: false,
199+
quiet: true,
200+
noColor: true,
201+
yes: false,
202+
dryRun: false,
203+
nonInteractive: true,
204+
async: false,
205+
},
206+
{
207+
apiKey: 'explicit-cn-key',
208+
region: 'cn',
209+
quiet: true,
210+
verbose: false,
211+
noColor: true,
212+
yes: false,
213+
dryRun: false,
214+
help: false,
215+
nonInteractive: true,
216+
async: false,
217+
},
218+
);
219+
220+
expect(readConfigFile()).toEqual({ api_key: 'explicit-cn-key', region: 'cn' });
221+
expect(stderr).toContain('Warning: Could not validate the API key');
222+
expect(stderr).toContain('Saving the explicitly selected region "cn"');
223+
} finally {
224+
(process.stderr as NodeJS.WriteStream).write = originalWrite;
225+
(REGIONS as Record<string, string>).cn = originalCn;
226+
}
227+
});
228+
229+
it('keeps existing credentials when auto-detection cannot reach either region', async () => {
230+
configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-'));
231+
process.env.MMX_CONFIG_DIR = configDir;
232+
await writeConfigFile({ api_key: 'existing-key', region: 'cn' });
233+
234+
const originalGlobal = REGIONS.global;
235+
const originalCn = REGIONS.cn;
236+
(REGIONS as Record<string, string>).global = 'http://127.0.0.1:1';
237+
(REGIONS as Record<string, string>).cn = 'http://127.0.0.1:1';
238+
239+
try {
240+
try {
241+
await loginCommand.execute(
242+
{
243+
region: 'cn',
244+
baseUrl: originalCn,
245+
output: 'text',
246+
timeout: 1,
247+
verbose: false,
248+
quiet: true,
249+
noColor: true,
250+
yes: false,
251+
dryRun: false,
252+
nonInteractive: true,
253+
async: false,
254+
},
255+
{
256+
apiKey: 'unverifiable-key',
257+
quiet: true,
258+
verbose: false,
259+
noColor: true,
260+
yes: false,
261+
dryRun: false,
262+
help: false,
263+
nonInteractive: true,
264+
async: false,
265+
},
266+
);
267+
throw new Error('Expected region detection to fail');
268+
} catch (error) {
269+
expect(error).toBeInstanceOf(CLIError);
270+
expect((error as CLIError).exitCode).toBe(ExitCode.NETWORK);
271+
}
272+
273+
expect(readConfigFile()).toEqual({ api_key: 'existing-key', region: 'cn' });
109274
} finally {
110275
(REGIONS as Record<string, string>).global = originalGlobal;
111276
(REGIONS as Record<string, string>).cn = originalCn;

0 commit comments

Comments
 (0)