Magic Tools
Pitfall NotesBy CooconSeptember 27, 202614 views11 min read

Claude Code "Invalid API key · Fix external API key": Not logged in, Credit balance is too low, API Error 401/429/529 — Exact Messages and Retry Behavior, Tested

Background

Point Claude Code at a compatible endpoint like DeepSeek, swap in a new key, or run out of credit, and claude -p will often print one of these:

Invalid API key · Fix external API key
Not logged in · Please run /login
Credit balance is too low
API Error: Request rejected (429) · …
API Error: 529 Overloaded. This is a server-side issue, usually temporary — …

The messages are readable enough. The hard questions are these:

  • Did Claude Code decide this locally, or did the backend return it? Was a request even sent?
  • Why does a wrong key make the command hang for almost 3 minutes before failing?
  • In a script, is the error on stdout or stderr? What is the exit code? Which field in --output-format json should you check?
  • If you run a third-party gateway, should you read x-api-key or Authorization: Bearer?

Every message and number below comes from runs on Claude Code 2.1.280 on this machine on 2026-09-27: 28 cases, 61 claude -p runs in total. The backend is not the real API but a local stub, so the status code can be anything I want, and the arrival time and headers of every request are recorded.

Analysis

I first located the definitions of these messages in the 2.1.280 native binary (claude.exe, 217 MB):

var _Ye="Not logged in \xB7 Please run /login",
    Gke="Invalid API key \xB7 Fix external API key",
    pat="Invalid auth token \xB7 Fix external auth token",
    mat="Invalid ANTHROPIC_CUSTOM_HEADERS \xB7 Fix the environment variable"

There is also a check: function JM(e){return/^[a-zA-Z0-9-_]+$/.test(e)}, which throws Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores. on failure.

From the static code alone it is tempting to conclude that "a key with special characters is rejected locally". The test run disproved this: ANTHROPIC_API_KEY='bad key!@#' was sent to the backend verbatim in x-api-key, the stub returned a normal response, and the run exited 0. Going back to the code, the only call site of edo() is in the OAuth API-key creation flow (r.data?.raw_key → edo(s,n)); a key from an environment variable never passes through it. So every conclusion here rests on the runs; static code is only used to explain what was observed.

Reading the code also turned up the logic that picks the message, later confirmed by the runs: whether a 401 shows Invalid API key depends on whether the error message contains the string x-api-key:

function uat(e){return e instanceof Error&&e.message.toLowerCase().includes("x-api-key")}
// ...
if(uat(e)){ …; return Ro({error:"authentication_failed",
  content: M==="ANTHROPIC_API_KEY"||M==="apiKeyHelper" ? Gke : _Ye}) }

Approach and alternatives

The goal is to assert "was a request sent, how many times, at what intervals, with which header", so I need control over what the backend returns and a record of every request that arrives.

Option Verdict Reason
Local stub (zero-dependency Node) ✅ Used Any status code and body; every request logged to jsonl with a millisecond timestamp and all headers; costs nothing and bothers no real service
Real key against api.anthropic.com ❌ Rejected Can't produce 429/529/5xx on demand; a wrong key retried over and over is pointless load on the official API
A real third-party endpoint (DeepSeek, etc.) ❌ Rejected The error body format is theirs, not mine, so variables can't be controlled; the same error may come back differently at different times
mitmproxy capture ❌ Rejected Needs certificate trust changes and adds another variable; ANTHROPIC_BASE_URL can point straight at an http stub, so it isn't needed
Reading strings in the binary only ❌ Not on its own The format check above is the counterexample: it exists in the code but does not fire on this path

How it works:

  • lab/stub.mjs listens on 127.0.0.1 and, per case config, answers POST /v1/messages with valid SSE, a chosen status code and body, or a dropped connection.
  • Each run starts with env -i and a brand-new empty CLAUDE_CONFIG_DIR, so the machine's OAuth login is never reused; the working directory is separate too.
  • HTTPS_PROXY also points at the stub: any CONNECT is logged with its target host and answered 403. So every outbound connection is recorded and blocked, guaranteeing no request in this experiment reached the real api.anthropic.com.
  • Invocation is always: claude -p 'reply with exactly OK' --model haiku --output-format text|json < /dev/null.

Test runs

The control run goes first. The stub returns a valid Messages stream (message_start → content_block_delta: "OK" → message_stop). All 3 runs exited 0 with stdout byte-for-byte 4f 4b 0a (OK\n); the stub got 1 request each, carrying x-api-key, anthropic-version: 2023-06-01, and user-agent: claude-cli/2.1.280 (external, sdk-cli). The stub is trustworthy, so the failures below can be attributed.

Every case ran at least twice, and the message, exit code and request count matched every time. This table is the core of the article. "Stub requests" is what the stub actually logged, not an inference:

Message (stdout) Trigger Local or backend Stub requests Retried? Wall time
Not logged in · Please run /login Neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN set, not logged in Local 0 — 0.28 / 0.30 s
Invalid API key · Fix external API key · Invalid X-Api-Key header value from ANTHROPIC_API_KEY: it contains a non-ASCII character at character 4 (9 characters). Key contains Chinese (sk-密钥-123) Local 0 No 0.28 / 0.27 s
… it contains a line break at character 7 (10 characters on 2 lines). Newline in the middle of the key Local 0 No 0.27 / 0.27 s
Invalid auth token · Fix external auth token · Invalid Authorization header value from ANTHROPIC_AUTH_TOKEN: it contains a non-ASCII character at character 5 (10 characters). Token contains Chinese Local 0 No 0.28 / 0.28 s
Invalid API key · Fix external API key Backend 401, message contains invalid x-api-key Backend 11 Yes, 10 times 179.43 / 168.78 s
Same as above Backend 401, type changed to invalid_request_error, message still contains x-api-key Backend 11 Yes 176.93 / 183.28 s
Failed to authenticate. API Error: 401 invalid api key Backend 401, message is invalid api key (no x-api-key) Backend 11 Yes 180.78 / 176.96 s
Not logged in · Please run /login Only ANTHROPIC_AUTH_TOKEN set, backend 401 Backend 11 Yes 181.07 / 179.16 s
Credit balance is too low 402 billing_error, message is the official credit balance text Backend 1 No 0.93 / 0.75 s
Credit balance is too low 400 invalid_request_error, same message Backend 1 No 0.99 / 0.71 s
Failed to authenticate. API Error: 403 Your API key does not have permission to use the specified resource. 403 permission_error Backend 1 No 0.98 / 0.72 s
API Error: 400 Model Not Exist 400 invalid_request_error Backend 1 No 0.96 / 0.68 s
There's an issue with the selected model (claude-haiku-4-5-20251001). It may not exist or you may not have access to it. Run --model to pick a different model. 404 not_found_error Backend 2 One extra non-streaming request only 0.95 / 0.77 s
API Error: Request rejected (429) · Number of request tokens has exceeded your per-minute rate limit 429 + retry-after: 1 Backend 11 Yes, 10 times 179.06 / 184.21 s
API Error: 529 Overloaded. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:18913). 529 overloaded_error Backend 11 Yes 180.24 / 180.05 s
API Error: 500 Internal server error. …check your inference gateway (…) 500 api_error Backend 11 Yes 179.81 / 180.51 s
API Error: 502 Bad Gateway. …check your inference gateway (…) 502 + nginx HTML page Backend 11 Yes 178.63 / 171.14 s
API Error: API returned an empty or malformed response (HTTP 200) — check for a proxy or gateway intercepting the request. … 200, but body is HTML or empty Backend 2 One extra non-streaming request only 0.96 / 0.78 s
API Error: Connection dropped (ECONNRESET) Stub drops the connection after receiving the request Backend (network) 11 Yes 185.36 / 171.91 s
API Error: Connection refused — a firewall or proxy may be blocking it (ECONNREFUSED) Nothing listening on the base URL port Local network layer 0 Yes, full retries anyway 184.27 / 182.76 s

Two things deserve attention:

  1. The same message can have two sources. Invalid API key · Fix external API key can be a backend 401 (3 minutes of retries) or the local header check (0.28 s, followed by · Invalid X-Api-Key header value…). Same with Not logged in: either there are no credentials locally at all, or a token was rejected with 401 by the backend. The latter sends you off to run /login when what you actually need is a new token.
  2. The format check never fired. With ANTHROPIC_API_KEY='bad key!@#' both runs exited 0 and printed OK; the x-api-key value the stub received was exactly bad key!@#. Invalid API key format… was not reproduced. Also, a trailing newline on the key is not an error: the stub received sk-abc123 with the newline stripped.

Local rejection: API key contains a non-ASCII character, stub got 0 requests, exited in 0.28 s

Retry timing: even a 401 is retried 10 times

Request intervals the stub logged for the 401 case (ms, r1):

545, 1020, 2254, 4300, 8248, 17336, 34291, 34866, 39673, 36396

That is roughly 0.5 → 1 → 2 → 4 → 8 → 16 s, doubling each time, capped at 32–40 s from the 7th retry on. It gives up after 10 retries; first-to-last span is 168.5–184.8 s. The 11 data sets for 429, 529, 500, 502 and ECONNRESET follow the same curve. Every request carried x-stainless-retry-count: 0, which means this is Claude Code's own application-level retry, not the SDK's.

429's retry-after: 1 is honored: the first interval across 3 runs was 1009 / 1005 / 1006 ms, while for other status codes it was between 529 and 628 ms. After that, intervals match the other status codes.

The defaults in the code match what was measured: Vur=10 (default retry count) and GCe=15 (the cap for CLAUDE_CODE_MAX_RETRIES; larger values are clamped with a warning). There is also Yur=300, a default for some mode; what triggers it was not determined.

Backend 401 invalid x-api-key: 10 retries, Invalid API key shown only after 179 s

Turning retries off: CLAUDE_CODE_MAX_RETRIES

Same 401 stub, with just one environment variable added:

Setting Stub requests Intervals Wall time (2 runs) stdout
Default 11 see above 179.43 / 168.78 s Invalid API key · Fix external API key
CLAUDE_CODE_MAX_RETRIES=2 3 628, 1082 / 548, 1124 ms 2.01 / 1.97 s same
CLAUDE_CODE_MAX_RETRIES=0 1 — 0.28 / 0.27 s same

stdout, stderr, exit code

Across all 61 runs, stderr was 0 bytes every time; every error went to stdout with a trailing \n; failures were always exit 1, successes exit 0. In practice:

  • 2>err.log captures nothing, and any "empty stderr means success" check will be wrong.
  • The exit code tells you something failed, but not whether it was a bad key, a rate limit, or a dead gateway — it's 1 for all of them.

429 + retry-after: 1: also 10 retries, 179 s, exit 1

What --output-format json looks like

Key fields from the three json-mode runs:

case subtype is_error api_error_status terminal_reason result duration_ms
No credentials "success" true null "api_error" Not logged in · Please run /login 130
401 "success" true 401 "api_error" Invalid API key · Fix external API key 171725
429 "success" true 429 "api_error" API Error: Request rejected (429) · … 182737

subtype is still "success" on an API error, so checking it alone will read failure as success. Check is_error or terminal_reason instead. The result key is present and holds the error message. This differs from the Reached max turns article, where the subtype is error_max_turns and the result key may be missing entirely. api_error_status tells you the source: backend errors carry the HTTP status, local credential problems are null.

Third-party gateways: which header is actually sent

Environment variables Auth header received by the stub
Only ANTHROPIC_API_KEY x-api-key: <key>
Only ANTHROPIC_AUTH_TOKEN authorization: Bearer <token>
Both Both are sent: x-api-key: sk-stub-KEY-111 + authorization: Bearer tok-stub-TOKEN-222

With both set, Claude Code doesn't choose for you: the gateway receives both headers and picks by its own precedence. If the two values belong to different accounts, problems get very hard to trace.

Results

The readings, turned into things you can act on:

1. Client side: set only one of ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN. If the gateway docs ask for Bearer, set only TOKEN; if they ask for x-api-key, set only KEY. Then verify quickly with this instead of waiting 3 minutes:

CLAUDE_CODE_MAX_RETRIES=0 claude -p "reply with exactly OK" --output-format json < /dev/null \
  | jq '{is_error, api_error_status, result}'

api_error_status is null with is_error:true → the problem is local credentials (not set, or containing non-ASCII characters or an embedded newline); 401 → the backend rejected the key.

2. Seeing Not logged in · Please run /login? Check which variable you set first. If it's ANTHROPIC_AUTH_TOKEN, this most likely means the token got a 401, and running /login won't help.

3. In scripts / CI: decide success by exit code or is_error, and read the error text from stdout. For health-check-style calls, set CLAUDE_CODE_MAX_RETRIES=0 or a small value, or one misconfigured key stalls the pipeline for 3 minutes. Don't set it to 0 for long-running jobs — 429/529 are exactly what backoff retries are for.

4. Gateway side (for people building a compatibility layer):

  • Accept both x-api-key and Authorization: Bearer; when a request carries both, define your precedence explicitly.
  • If you return 401 for an invalid key, Claude Code retries 10 more times, so one failed call reaches you as 11 requests. 403 and 400 were not retried in the runs (1 request). Whether to change your status code over this depends on your semantics; this is just the measured behavior.
  • Put the string x-api-key in the 401 message and users see Invalid API key · Fix external API key; leave it out and they see Failed to authenticate. API Error: 401 <your message>. Both were tested.
  • For insufficient credit, as long as the message contains credit balance is too low, both 402 and 400 display as Credit balance is too low. A bare 402 whose message lacks that text was not tested.
  • 5xx errors include check your inference gateway (<your host:port>), so users will suspect your gateway first.

5. More base URL pitfalls: for ANTHROPIC_BASE_URL being ignored, see this article; for a full DeepSeek setup, see Running Claude Code on DeepSeek.

All 61 runs hit the local stub, so real API spend was 0. The total_cost_usd: 0.000015 in the control run's JSON is the stub's returned usage (10 input / 1 output tokens) priced at haiku rates, not a real charge.

Pitfalls

  • Claude Code still connects to api.anthropic.com with ANTHROPIC_BASE_URL set. Every run issued CONNECT api.anthropic.com:443: 5 times for a normal run, 2 with no credentials, 2 with only a token, and 18–23 for runs that used all 10 retries. 690 in total, all targeting api.anthropic.com:443, all blocked by the stub proxy with 403 — and the control run still exited 0. What these connections are for isn't in the debug log; not attributed. If your network can't reach that domain, this probably doesn't affect the main request (it was blocked throughout this run), but that is the only network condition tested.
  • I trusted a static clue at first. The original plan featured "malformed key → local Invalid API key format" as the headline; in practice bad key!@# simply succeeded. A string in the binary only proves the code contains it, not that it fires on your path.
  • Local grep on a large binary is too slow. BSD grep with .{90}fragment.{140} over 217 MB took about 35 minutes to return 2 fragments; I switched to python mmap to find literal offsets and slice around them. This was already recorded in the Bash timeout article, and I made the same mistake again.
  • Each retry case takes about 3 minutes. The first pass ran the 4xx group serially; only after 600+ s did I realize 401 was being retried too. I stopped it and switched to one stub per case, run in parallel. The interrupted run was redone; the table shows the rerun data.
  • About the screenshots. The output text in all three images is the raw stdout bytes captured by the experiment runner. The real line is the wall time recorded by the runner, not actual output from the shell's time. In image 2, sk-wrong in the command is a display placeholder; the key actually used was sk-stub-valid-123, and the stub returned 401. In image 1 the key was passed via environment variable (sk-密钥-123) and isn't shown on the command line. Separately, 5 fast cases were recorded on a real TTY with script(1) and matched the runner's stdout byte for byte (-p text mode has no color).

Not verified: Invalid API key format… (not reproduced; only in the OAuth key-creation flow); Invalid ANTHROPIC_CUSTOM_HEADERS (not tested); 402 without the credit text in the message (not tested); a 429-then-success recovery sequence (not tested); display and retries in an interactive (non--p) session (not tested); what triggers Yur=300 and what those CONNECTs are for (not attributed). Each case ran only twice, so the jitter range of the retry intervals is based on those samples alone.

Further reading

Related Articles

Claude Code "Command timed out after 2m 0s": Two Timeout Paths, BASH_DEFAULT_TIMEOUT_MS and run_in_background Tested

Claude Code "Command timed out after 2m 0s": Two Timeout Paths, BASH_DEFAULT_TIMEOUT_MS and run_in_background Tested

Claude Code's Bash tool times out after 120 seconds by default. I ran 19 real sessions on 2.1.280 and found two timeout paths: only commands whose first word is sleep get killed with Exit code 143 / Command timed out after 2m 0s; everything else is moved to the background and killed 5 seconds after claude -p winds down. Either way claude exits 0, stderr is 0 bytes and the JSON top level says is_error=false. BASH_DEFAULT_TIMEOUT_MS=8000 killed at 8.17s; 0 or abc silently fall back to 120s; an explicit timeout above BASH_MAX_TIMEOUT_MS was silently clamped to 15s.

claude-codetroubleshooting+4
pitfallsSep 26, 20269 min
25
Claude Code "Error: Reached max turns (1)": when the headless guardrail fires, your file may already be written

Claude Code "Error: Reached max turns (1)": when the headless guardrail fires, your file may already be written

claude -p guardrails stop with Error: Reached max turns (1) or Error: Exceeded USD budget (0.01), exit 1. On 2.1.270 and 2.1.280 they are 28 and 33 bytes on stdout, not stderr, with no newline. In json mode the result key is missing, so jq -r .result prints null with jq exit 0. Failure does not mean nothing happened: --max-turns 2 errors after out.txt is written, and the budget is checked after each call, so a $0.05 cap spent $0.0517, finished the task and still exited 1.

claude-codeautomation+5
hands-onSep 25, 202611 min
61
Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one

Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one

Six causes behind Claude Code's MCP "Failed to connect", reproduced on 2.1.280: mcp list exits 0 on failure, CONNECTION_CLOSED hides two causes that only --debug-file reveals, and one server that never handshakes pushes claude -p wall time 4.63s → 36.21s, duration_ms only 4323.5 → 5930.

mcpclaude-code+4
pitfallsSep 24, 202610 min
92

DeepSeek Says 1M, Claude Code Says 200K: I Measured Both and Neither Number Is the Real Limit

DeepSeek advertises a 1M context window. Point Claude Code at it and Claude Code reports contextWindow 200000 for the same model. I measured what actually happens. DeepSeek's real ceiling is 1,048,576 tokens — literally 2^20, not one million — and it covers input plus your max_tokens budget, proven with a controlled pair. A needle planted at position zero was retrieved correctly at 1,039,744 tokens. Claude Code refuses client-side long before that, in 25ms with zero API calls, and its gate is not on tokens at all: it fires at roughly 480,000 characters. Feed it high-entropy text and 478,000 characters sails through carrying 309,567 real tokens — 55% past the 200K window it just claimed. And in ordinary use you reach none of these, because Bash output over exactly 30,000 characters never enters context at all.

claude-codelong-context+5
hands-onSep 20, 20267 min
116

Published by Magic Tools