Skip to content

Commit 06eec7a

Browse files
authored
Merge pull request #194 from MiniMax-AI/codex/region-detection-fail-closed
fix: fail closed when API key region detection is inconclusive
2 parents 5708a06 + b4039d8 commit 06eec7a

6 files changed

Lines changed: 399 additions & 49 deletions

File tree

src/commands/auth/login.ts

Lines changed: 59 additions & 13 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);
@@ -76,7 +107,7 @@ export default defineCommand({
76107
}
77108

78109
if (flags.apiKey) {
79-
await loginWithApiKey(config, flags.apiKey as string);
110+
await loginWithApiKey(config, flags.apiKey as string, flags.region as Region | undefined);
80111
return;
81112
}
82113

@@ -109,7 +140,7 @@ export default defineCommand({
109140
validate: (v) => (v && v.length > 0 ? undefined : 'API key cannot be empty.'),
110141
});
111142
if (isCancel(key)) throw new CLIError('Authentication cancelled.', ExitCode.AUTH);
112-
await loginWithApiKey(config, key as string);
143+
await loginWithApiKey(config, key as string, flags.region as Region | undefined);
113144
return;
114145
}
115146

@@ -131,26 +162,41 @@ async function completeOAuthLogin(config: Config, region: Region): Promise<void>
131162
await showDashboardAfterLogin(cfg);
132163
}
133164

134-
async function loginWithApiKey(config: Config, key: string): Promise<void> {
165+
async function loginWithApiKey(
166+
config: Config,
167+
key: string,
168+
explicitRegion?: Region,
169+
): Promise<void> {
135170
if (config.dryRun) {
136171
console.log('Would validate and save API key.');
137172
return;
138173
}
139174

140-
// Probe both regions and pick the one the key actually authenticates against.
141-
// This doubles as key validation — if neither region accepts it, the key is bad.
142-
const detected = await detectRegion(key);
175+
// An explicit region is authoritative. Otherwise probe both regions and fail
176+
// closed if neither can be confirmed; never persist a guessed fallback.
177+
const detected = explicitRegion ?? await detectRegion(key);
143178
const cfg: Config = { ...config, region: detected, baseUrl: REGIONS[detected], apiKey: key };
144179

145-
// Verify the detection actually authorizes the quota endpoint (defends against
146-
// detectRegion's graceful 'global' fallback when the network is unreachable).
180+
// Verify the selected region actually authorizes the quota endpoint.
147181
try {
148182
await requestJson<QuotaApiResponse>(cfg, { url: quotaEndpoint(cfg.baseUrl) });
149-
} catch {
150-
throw new CLIError(
151-
'API key validation failed.',
152-
ExitCode.AUTH,
153-
'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`,
154200
);
155201
}
156202

src/config/detect-region.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { REGIONS, type Region } from "./schema";
22
import { readConfigFile, writeConfigFile } from "./loader";
3+
import { CLIError } from "../errors/base";
4+
import { ExitCode } from "../errors/codes";
35

46
const QUOTA_PATH = "/v1/token_plan/remains";
57

8+
type ProbeResult = "authorized" | "unauthorized" | "unreachable" | "inconclusive";
9+
610
function quotaUrl(region: Region): string {
711
return REGIONS[region] + QUOTA_PATH;
812
}
@@ -11,7 +15,7 @@ async function probeRegion(
1115
region: Region,
1216
apiKey: string,
1317
timeoutMs: number,
14-
): Promise<boolean> {
18+
): Promise<ProbeResult> {
1519
// MiniMax endpoints accept either Bearer or x-api-key auth — try both.
1620
// Some API key types only work with one style; trying both prevents false
1721
// negatives that would cause the wrong region to be selected, leading to
@@ -21,22 +25,41 @@ async function probeRegion(
2125
{ "x-api-key": apiKey },
2226
];
2327

28+
let sawNetworkFailure = false;
29+
let sawInconclusiveResponse = false;
30+
2431
for (const authHeader of authHeaders) {
32+
let res: Response;
2533
try {
26-
const res = await fetch(quotaUrl(region), {
34+
res = await fetch(quotaUrl(region), {
2735
headers: { ...authHeader, "Content-Type": "application/json" },
2836
signal: AbortSignal.timeout(timeoutMs),
2937
});
30-
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 {
3150
const data = (await res.json()) as {
3251
base_resp?: { status_code?: number };
3352
};
34-
if (data.base_resp?.status_code === 0) return true;
53+
if (data.base_resp?.status_code === 0) return "authorized";
54+
sawInconclusiveResponse = true;
3555
} catch {
36-
// Try next auth style before giving up on this region
56+
sawInconclusiveResponse = true;
3757
}
3858
}
39-
return false;
59+
60+
if (sawInconclusiveResponse) return "inconclusive";
61+
if (sawNetworkFailure) return "unreachable";
62+
return "unauthorized";
4063
}
4164

4265
export async function detectRegion(apiKey: string): Promise<Region> {
@@ -45,19 +68,34 @@ export async function detectRegion(apiKey: string): Promise<Region> {
4568
const results = await Promise.all(
4669
regions.map(async (r) => ({
4770
region: r,
48-
ok: await probeRegion(r, apiKey, 5000),
71+
result: await probeRegion(r, apiKey, 5000),
4972
})),
5073
);
51-
const match = results.find((r) => r.ok);
74+
const match = results.find((r) => r.result === "authorized");
5275
if (!match) {
5376
process.stderr.write(" failed\n");
54-
process.stderr.write(
55-
`Warning: API key failed validation against all regions (global, cn).\n` +
56-
` This usually means the API key is invalid or the network is blocking requests.\n` +
57-
` Falling back to 'global'. Subsequent requests may fail.\n` +
58-
` Run 'mmx auth status' to verify your credentials.\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+
94+
throw new CLIError(
95+
"Could not determine the API key region.",
96+
ExitCode.GENERAL,
97+
"No region was changed because the API returned an inconclusive response. Retry later or pass --region global|cn explicitly.",
5998
);
60-
return "global";
6199
}
62100
const detected: Region = match.region;
63101
process.stderr.write(` ${detected}\n`);

test/auth/timeout-fix.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('detect-region: probeRegion auth style fallback', () => {
7777
}
7878
});
7979

80-
it('falls back to global when key is invalid for all auth styles and regions', async () => {
80+
it('fails closed when key is invalid for all auth styles and regions', async () => {
8181
server = createMockServer({
8282
routes: {
8383
'/v1/token_plan/remains': () =>
@@ -93,8 +93,37 @@ describe('detect-region: probeRegion auth style fallback', () => {
9393

9494
try {
9595
const { detectRegion } = await import('../../src/config/detect-region');
96-
const region = await detectRegion('bad-key');
97-
expect(region).toBe('global'); // graceful fallback
96+
try {
97+
await detectRegion('bad-key');
98+
throw new Error('Expected region detection to fail');
99+
} catch (error) {
100+
expect(error).toBeInstanceOf(CLIError);
101+
expect((error as CLIError).message).toBe('API key was rejected by all regions.');
102+
expect((error as CLIError).exitCode).toBe(ExitCode.AUTH);
103+
}
104+
} finally {
105+
(REGIONS as Record<string, string>).global = origGlobal;
106+
(REGIONS as Record<string, string>).cn = origCn;
107+
}
108+
});
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+
}
98127
} finally {
99128
(REGIONS as Record<string, string>).global = origGlobal;
100129
(REGIONS as Record<string, string>).cn = origCn;

0 commit comments

Comments
 (0)