Field notes from a blocked network

Claude Code throws ERR_BAD_REQUEST against api.anthropic.com from China. Here is the actual fix.

The short version: api.anthropic.com is not reachable from mainland China, the request dies inside axios before it leaves your laptop, and the answer is ANTHROPIC_BASE_URL pointed at an Anthropic-protocol-compatible gateway that does resolve from inside the GFW. The long version is the three traps that make this five lines of config into a half-day debug session: a missing URL scheme that silently bricks every query, the onboarding ping that bypasses the variable on first launch, and the variable-read-once rule that makes mid-session edits look like a bug.

M
Matthew Diakonov
9 min read

Direct answer (verified 2026-05-29)

Set ANTHROPIC_BASE_URL to a fully-qualified Anthropic-compatible gateway URL with the scheme included. Two reachable examples from mainland China are https://api.deepseek.com/anthropic and https://api.z.ai/api/anthropic. Pair it with ANTHROPIC_AUTH_TOKEN (sent as the bearer credential). Before the first launch, seed ~/.claude/settings.json with "hasCompletedOnboarding": true so the interactive shell does not ping api.anthropic.com on startup (issue #36998 and #26935). Fully quit and relaunch claude any time you change the value; the variable is read once when the process spawns.

Authoritative source: code.claude.com/docs/en/errors and the open issues linked above.

What ERR_BAD_REQUEST actually means here

Claude Code is a TypeScript program. Its HTTP client is axios. ERR_BAD_REQUEST is not an HTTP status code; it is an axios-internal error code that fires when the request fails on the client side before a clean response can be parsed. There are two common ways to land in this bucket from China:

  • The target host does not resolve or refuses the TLS handshake. api.anthropic.com from inside the GFW is unreachable at the network layer; the SDK tries to dial and the connection never opens cleanly, so axios surfaces it as a bad-request-shaped failure rather than a real HTTP error.
  • The destination URL fails to parse. If ANTHROPIC_BASE_URL is set but malformed (missing the scheme, stray characters, a port without a host), Node's URL constructor throws and the SDK surfaces it as a request error. This is the silent killer covered in the third section.

The message text varies between SDK versions. What it usually looks like:

claude (interactive) from a Chinese network

The five-line fix, with the schemes written in

The cleanest place to land the variables is ~/.claude/settings.json under the env key. That file is read every time claude starts and applied on top of the inherited shell environment, so you do not have to remember which terminal you exported the value in. Pick one of the three gateways below and paste the URL exactly as written:

// ~/.claude/settings.json
{
  "hasCompletedOnboarding": true,
  "env": {
    "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
    "ANTHROPIC_AUTH_TOKEN": "YOUR_GATEWAY_TOKEN"
  }
}

Or the equivalent shell export, if you would rather keep your config out of a file:

# zsh or bash, before launching claude
export ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
export ANTHROPIC_AUTH_TOKEN="YOUR_GATEWAY_TOKEN"
claude

The three URLs people actually use from inside China, with the path segments that matter:

Verified resolving as of 2026-05

Anthropic-compatible gateways reachable from mainland China

DeepSeek

https://api.deepseek.com/anthropic. Hosts the V series. Bearer-token auth via ANTHROPIC_AUTH_TOKEN.

z.ai GLM

https://api.z.ai/api/anthropic. Hosts the GLM 4 series. Bearer-token auth via ANTHROPIC_AUTH_TOKEN.

Corporate proxy

Your gateway URL. Whatever forwards Anthropic Messages API requests and adds the upstream credential server-side.

None of these are Anthropic. They are gateways that accept POST requests in the Anthropic Messages API shape and translate to the provider's own models on the server side. The model name Claude Code sends gets remapped on the gateway side; the agent loop, the tool surface, and the streaming semantics are unchanged because they are part of the protocol, not the model.

Trap 1: the missing scheme that silently bricks every query

If you paste a gateway URL without the https:// prefix, the Anthropic SDK cannot parse it as an absolute URL and every chat throws API Error: Invalid URL. There is no log line that says you typed the URL wrong. In an interactive shell it can look like the agent has frozen; in a wrapped chat the retry-with-resume path can swallow the error into an empty turn so you see nothing at all.

Anchor fact, traceable in the repo

Fazm is a native macOS app that wraps the same Claude Code agent loop. Because the bridge spawns the Claude Code process, it owns the environment, including ANTHROPIC_BASE_URL. The repository is MIT-licensed at github.com/m13v/fazm and the validator lives at Desktop/Sources/Chat/ACPBridge.swift lines 658 to 668. The verbatim inline comment is:

// Custom API endpoint (allows proxying through Copilot, corporate gateways, etc.)
// Only forward it when it's an absolute http(s) URL with a host. A malformed value
// (missing scheme, "localhost:8766", stray text) otherwise lands in ANTHROPIC_BASE_URL
// and makes the Anthropic SDK throw "API Error: Invalid URL" on every query, silently
// bricking built-in chat (the retry-with-resume path can even swallow it into an empty
// turn so the user sees no error at all). Falling back to the default keeps chat working.
if let customEndpoint = defaults.string(forKey: "customApiEndpoint")?
  .trimmingCharacters(in: .whitespacesAndNewlines), !customEndpoint.isEmpty {
  if let url = URL(string: customEndpoint),
    let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
    let host = url.host, !host.isEmpty {
    env["ANTHROPIC_BASE_URL"] = customEndpoint
  } else {
    log("ACPBridge: ignoring malformed customApiEndpoint ...")
  }
}

The shape of the check is the part to copy if you are running the raw CLI: URL(string:) has to succeed, the scheme has to be http or https, and the host has to be non-empty. If your gateway value misses any of those, treat it as the same as not setting the variable at all.

Trap 2: the onboarding ping that bypasses the variable on first launch

This one is the genuinely surprising bug. Claude Code's interactive mode does one direct HTTPS call to api.anthropic.com on startup if hasCompletedOnboarding is not already true in your local settings. The call happens before ANTHROPIC_BASE_URL is consulted. From inside the GFW that initial check times out, the process exits, and you see ERR_BAD_REQUEST or a sibling axios code with no clear pointer to the onboarding step. The two issue threads that track this are anthropics/claude-code#36998 and #26935.

The workaround is to write the onboarding flag in by hand before the first launch:

// ~/.claude/settings.json - write this BEFORE the first `claude` run
{
  "hasCompletedOnboarding": true,
  "env": {
    "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
    "ANTHROPIC_AUTH_TOKEN": "YOUR_GATEWAY_TOKEN"
  }
}

With the flag set the process skips the initial probe, lands on the gateway you configured, and the first query succeeds. Until the upstream issues close, this is the standard procedure for any blocked network: VPN-free China, restrictive corporate egress, air-gapped regional environments, anywhere the public Anthropic endpoint is unreachable at the network layer.

Trap 3: the variable is read once, and only once

Environment variables are copied into a process at the moment it is spawned. From there the process holds its own private snapshot. Editing your shell profile, exporting a new value in another terminal, or rewriting settings.json does not reach into a running process. Claude Code reads ANTHROPIC_BASE_URL at startup and keeps that value until it exits. A change only takes effect on the next launch.

For the bare CLI this is mild: quit and relaunch. For long-lived wrappers it is the whole design problem; the host has to tear down and respawn the child. Here is the sequence, including the step where an edit silently does nothing:

ANTHROPIC_BASE_URL across the process lifecycle

Shell or hostclaude processGatewayspawn: ANTHROPIC_BASE_URL frozen into envevery API request goes to the gatewaystreamed completions backedit the URL while running: not seenfully quit and relaunch: env re-readrequests now route to the new gateway

The red message is the one that costs people an afternoon. There is no error, no warning, no log line. The edit lands in a file or a shell, the running process keeps its old snapshot, and the only signal you get is that traffic is still hitting the host you thought you changed.

Verify the fix before you trust the chat

The three checks below are the ones I run before I close the laptop. The order matters because each one rules out a different failure mode: env not present, env present but parsed wrong, env present and parsed but ignored by a stale onboarding probe.

Sanity checklist

  • Find the running claude PID and dump its environment. On macOS: ps -E -p <pid>. On Linux: cat /proc/<pid>/environ | tr \\0 \\n. ANTHROPIC_BASE_URL has to appear with the full https URL.
  • Send one trivial query and watch the network. lsof -i -P -n | grep claude on macOS should show the connection going to your gateway host, not api.anthropic.com.
  • Check the gateway's access log for an incoming request with the matching timestamp. If steps 1 and 2 pass but the gateway log is empty, the request is dying on your side; usually the bearer token is wrong or the path is missing.
  • Confirm hasCompletedOnboarding is true in ~/.claude/settings.json. If the flag is missing, the next launch can still ping api.anthropic.com before the base URL is consulted.
  • Fully quit and relaunch any time you change the URL. Editing the file under a running process does nothing until the next spawn.

When a wrapper is doing the variable for you

If you are running the Claude Code agent loop through a desktop wrapper, the same three traps still apply, they are just hidden behind a settings pane. The wrapper needs to do four things or it has the same failure modes as the CLI:

  • Validate the URL at the moment you save the setting so a paste without a scheme is rejected, not silently forwarded.
  • Inject the value into the child process at spawn, not into the wrapper's own environment, since the wrapper is not the thing that speaks to Anthropic.
  • Restart the child subprocess any time you change the setting so the new value actually reaches it. Editing a UserDefaults key while the subprocess is alive has the same staleness problem as editing a shell.
  • Surface upstream errors in a way that distinguishes your gateway's problem from Anthropic's. If the gateway returns no models loaded, or connection refused, the user needs to know it came from their endpoint, not from Claude Code.

Fazm's implementation of the last point lives at acp-bridge/src/api-failure.ts: a classifier that buckets thrown SDK errors into overloaded, credit, and other, so a 529 from Anthropic does not get hijacked into a billing message and a 400 from a mis-configured custom endpoint surfaces verbatim. The Swift side at ACPBridge.swift lines 2614 to 2627 layers on top: if the user has a custom endpoint set and the upstream returns connection refused or no models loaded, the UI message tells you which endpoint failed and how to toggle it off.

The point of the wrapper is not that it does anything the CLI cannot do. It is that you stop having to remember which of the three traps ate your last hour.

One last thing: tool search and the non-first-party host rule

The official environment-variable reference states that when ANTHROPIC_BASE_URL points at a non-first-party host, MCP tool search is disabled by default. The reason is that tool search depends on the upstream forwarding tool_reference blocks intact, and a proxy may strip them. If the gateway you chose does forward those blocks (DeepSeek, Moonshot, and z.ai all claim to in some form, but their compatibility shims around tool_reference vary), you can re-enable search by also setting ENABLE_TOOL_SEARCH=true. If your first tool-call query after the fix behaves differently from what you remember from the public API, that is the reason, not your gateway breaking.

Stuck behind a network that blocks api.anthropic.com?

Bring the gateway URL, the error text, and your ~/.claude/settings.json. We will route around the GFW or the corporate egress in one call.

Frequently asked questions

What does ERR_BAD_REQUEST actually mean inside Claude Code?

It is an axios error code, not an HTTP status. Claude Code's TypeScript stack uses axios as its HTTP client, and axios throws ERR_BAD_REQUEST when the request fails validation before the network call completes. The two common triggers are a malformed URL (a value in ANTHROPIC_BASE_URL that does not parse, e.g. a hostname with no scheme) and a request that the SDK could not assemble because its target host is unreachable. From China, an unreachable api.anthropic.com lands in the same bucket: the SDK tries to dial, the TLS handshake never completes, the resulting axios error gets surfaced as ERR_BAD_REQUEST or a related axios code. The fix in both cases is to point Claude Code somewhere that resolves, with a well-formed URL.

Why does Claude Code still hit api.anthropic.com when I have set ANTHROPIC_BASE_URL?

Two specific reasons. First, the interactive shell pings api.anthropic.com once on startup as part of the onboarding flow if hasCompletedOnboarding is not already true in your local settings (this is tracked as claude-code issue #36998 and #26935, both confirmed open as of mid 2026). If your network blocks api.anthropic.com that initial check fails and the process exits before the variable is even used. The workaround is to seed ~/.claude/settings.json with hasCompletedOnboarding: true before the first launch. Second, the variable is read once at process spawn time. If you change it in your shell after claude is already running, the running process keeps the old snapshot until you fully quit and relaunch.

Which Anthropic-compatible gateway URLs actually work from mainland China?

The two we ship as examples on this page are DeepSeek at https://api.deepseek.com/anthropic and z.ai GLM at https://api.z.ai/api/anthropic. Both speak the Anthropic Messages API on those paths, accept ANTHROPIC_AUTH_TOKEN as the bearer header, and resolve from inside China without a VPN. Each runs its own family of models (DeepSeek's V series and z.ai's GLM 4 series), so the model name Claude Code requests gets remapped on the gateway side. Moonshot Kimi exposes a similar Anthropic-compatible path; check their platform docs at platform.moonshot.ai for the current base path because they have shipped both .ai and .cn variants. None of these are Anthropic. They are Anthropic-protocol-compatible gateways, which is what the variable is designed for. Pricing, model quality, and tool-use fidelity differ; pick whichever one your code review reveals you actually like.

Why does pasting api.deepseek.com/anthropic without https:// silently break every chat?

Because the Anthropic SDK parses the value through Node's URL constructor before assembling a request. A string without a scheme is not a valid absolute URL; it parses as a relative path, the SDK cannot build a target, and every query throws API Error: Invalid URL. There is no log line that says you typed the URL wrong. From the user side it just looks like chat is broken. Fazm guards against this at ACPBridge.swift lines 658 to 668: it validates that customApiEndpoint has scheme http or https AND a non-empty host before assigning it to ANTHROPIC_BASE_URL, and falls back to the default Anthropic endpoint with a log line if it does not. If you are running the bare CLI, write the scheme in by hand and never trust your copy-paste.

Do I need ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, or both?

Use one, picked to match what the gateway expects. ANTHROPIC_AUTH_TOKEN is sent as the Authorization header with Bearer prefixed automatically; that is what DeepSeek, Moonshot, z.ai, and most corporate gateways want. ANTHROPIC_API_KEY is sent as x-api-key, which is what Anthropic itself accepts. Setting both is harmless if your gateway only reads one of them, but if a gateway honors x-api-key and the value is for a different service, you can end up authenticated against the wrong account. Read the gateway docs and set exactly one. If you are getting 401 or 403 after the URL fix, the variable choice or the credential value is wrong, not the base URL.

Will ANTHROPIC_BASE_URL break MCP tool search?

Partially, and on purpose. The official environment-variable reference states that when ANTHROPIC_BASE_URL points at a non-first-party host, MCP tool search is disabled by default because the upstream proxy may strip the tool_reference blocks that tool search depends on. If your gateway forwards those blocks intact you can re-enable it by also setting ENABLE_TOOL_SEARCH=true. DeepSeek, Moonshot, and z.ai all forward tool calls in some form but their compatibility shims around tool_reference vary, so test with one round-trip before assuming search works.

How do I verify the variable actually took effect before I trust the chat?

Three checks. First, ps -E -p <pid> on macOS or cat /proc/<pid>/environ on Linux to dump the running claude process environment and grep for ANTHROPIC_BASE_URL. If it is empty or wrong there, the value never made it into the process and an edit elsewhere will not help until you restart. Second, send one trivial query and watch the network: lsof -i -P -n | grep claude on macOS, or a packet capture, should show the connection going to your gateway host and not api.anthropic.com. Third, on the gateway side, check the access log for an incoming request with the matching timestamp. If only the env-var check passes and the network check shows traffic still going to Anthropic, the URL parsing failed silently and the SDK fell back, usually because of the missing-scheme trap above.

Does Claude Code support an HTTPS_PROXY for routing through a corporate or country proxy?

Yes, for traffic that respects standard Node proxy env vars. HTTPS_PROXY and HTTP_PROXY are read by the underlying HTTP libraries and will tunnel api.anthropic.com through your proxy if the proxy itself can reach Anthropic. From mainland China that usually does not solve the problem because the egress side of the proxy is the part that needs to leave the GFW. ANTHROPIC_BASE_URL is the better answer when the goal is to route to a different upstream rather than tunnel to the same one. Use HTTPS_PROXY when your corporate egress can reach Anthropic and you need every Node process on the machine to go through that hop. Use ANTHROPIC_BASE_URL when you are switching upstream providers.

Can a desktop wrapper set the variable for me instead of me editing files?

Yes. Fazm, the open-source macOS app this site documents, has a Custom API Endpoint toggle under Settings, Advanced, AI Chat. The URL is stored under the customApiEndpoint key in UserDefaults, validated at write time, and injected into the agent subprocess at spawn so it lands in ANTHROPIC_BASE_URL with the right shape. Toggling the field off clears it and restarts the bridge so the next query routes back to the default Anthropic endpoint. The validation step is the part that matters here: a malformed paste is caught before it ever silently bricks chat, and the running-process restart that the env-var lifecycle requires happens for you. The implementation is at ACPBridge.swift lines 658 to 668 and the restart logic is in ChatProvider.swift.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.