anthropics / anthropics/claude-code

[BUG] 2.1.236: silent OAuth token refresh fails behind an https:// CONNECT proxy (ERR_SOCKET_CLOSED) while API traffic succeeds

Open
#91,703 0 comments 0 reactions 0 assignees View on GitHub
area:auth area:networking bug has repro platform:macos
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20label%3Abug) and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code

### What's Wrong?

**This is a follow-up to #71796, on the version that fixed it.** That issue was closed as fixed in 2.1.222 with:

> The failure was in the startup connectivity check only (the main API transport already supported `https://` proxies), and it was fixed in **2.1.222**.

On **2.1.236** the startup check does now pass behind an `https://` CONNECT proxy — that fix works. But a **third** path is still broken: the **silent OAuth token refresh** (`POST https://platform.claude.com/v1/oauth/token`) hangs for ~15.4 s and fails with `ERR_SOCKET_CLOSED`, retries every 15–17 s, and never succeeds.

In the *same process, through the same proxy, in the same minutes*, normal API traffic to `api.anthropic.com` works fine. Removing `HTTPS_PROXY` makes the refresh succeed within seconds.

The proxy accepts and authenticates every tunnel and logs no error at all, so the failure is client-side, after the `CONNECT` succeeds.

(#71796 was scoped to the startup preflight, so I filed this separately rather than asking to reopen it — happy to have it merged in if you'd rather track it there. Note that a commenter on #71796 also reported "OAuth token exchange during login/authentication" failing with `Socket is closed`, which may be this same path seen at login time.)

### What Should Happen?

The OAuth token refresh should use the same proxy-aware transport as API requests — the change already made for the startup preflight in 2.1.222 — so that a session behind an `https://` CONNECT proxy can refresh its token and keep running.

### Error Messages/Logs

Claude Code persists telemetry it could not deliver to `~/.claude/telemetry/1p_failed_events...json`. From one session on 2026-09-02 (`additional_metadata` is base64-encoded JSON):

| Time (UTC) | Event | Decoded metadata |
|---|---|---|
| 11:55:24.643 | `tengu_oauth_token_refresh_failure` | `error_code: ERR_SOCKET_CLOSED`, `error_top_frame: cli:105:8707`, `error_message_hash: 6cbbb8cb8460` |
| 11:55:26.198 | `tengu_oauth_token_refresh_starting` | — |
| 11:55:41.580 | `tengu_oauth_token_refresh_failure` | same three values |
| 11:55:44.875 | `tengu_oauth_token_refresh_starting` | — |

`starting` → `failure` is **15.36 s and 15.38 s** — a fixed timeout, not jitter. Identical `error_code`, `error_top_frame` and `error_message_hash` on every failure indicate a single code path.

(These files hold only events that failed to *send*, so this is a lower bound on the number of failures.)

To extract them:

```sh
jq -r 'select(.event_data.event_name|test("oauth")) |
[.event_data.client_timestamp, .event_data.event_name,
.event_data.additional_metadata] | @tsv' \
~/.claude/telemetry/1p_failed_events.*.json
# then: printf '%s' "" | base64 -d | jq .
```

### Steps to Reproduce

1. Run a CONNECT proxy that terminates TLS (any `https://`-scheme proxy; dumbproxy with `-disable-http2` and Basic auth in my case, with a real publicly-trusted cert — no `NODE_EXTRA_CA_CERTS` needed).
2. Set `HTTPS_PROXY=https://:@:` in `~/.claude/settings.json`, leaving `platform.claude.com` out of `NO_PROXY`.
3. Use Claude Code normally until the access token expires (roughly a day). Startup and API traffic work fine throughout — this is past the preflight fixed in 2.1.222.
4. Observe: the session stalls; `~/.claude/telemetry/1p_failed_events.*.json` accumulates `tengu_oauth_token_refresh_failure` with `ERR_SOCKET_CLOSED` every 15–17 s; the proxy logs a matching `CONNECT` per attempt, authenticated, with no error.
5. Remove `HTTPS_PROXY` — the refresh succeeds within seconds.

### Claude Model

Opus

### Is this a regression?

I don't know

### Last Working Version

Not tested on older versions myself. Commenters on #71796 reported 2.1.159 / 2.1.190 working for the (related) proxy paths.

### Claude Code Version

2.1.236 (Claude Code)

### Platform

Anthropic API

### Operating System

macOS

### Terminal/Shell

Other

### Additional Information

**Environment**

| | |
|---|---|
| Claude Code | 2.1.236, Homebrew cask `claude-code` (stable channel) |
| Runtime | Bun 1.4.0 single-file executable (per strings in the shipped binary) |
| OS | macOS 15.7.3, Darwin 24.6.0, Apple Silicon |
| Terminal | Ghostty (not in the dropdown), zsh |
| Account | Pro subscription (`subscription_type: "pro"` in telemetry) |
| `HTTPS_PROXY` | `https://:@:8443` — TLS listener, Basic auth |
| `NO_PROXY` | `127.0.0.1,localhost` + unrelated internal domains |

The proxy scheme is `https://`, so the client must do **TLS inside TLS**: an outer TLS session to the proxy, `CONNECT`, then an inner TLS session to the origin over that tunnel.

---

**Proxy-side log, same window.** The proxy logged seven `CONNECT //platform.claude.com:443`, all authenticated, 15–17 s apart:

```
11:54:05 11:54:22 11:54:37 11:54:54 11:55:09 11:55:26 11:55:45
```

Two of them match the telemetry `refresh_starting` timestamps to within 0.4 s (11:55:26.198 → 11:55:26.611, 11:55:44.875 → 11:55:45.293), confirming one `CONNECT` per refresh attempt. Across that whole window the proxy emitted **33 INFO lines and zero WARN/ERROR** — it did not reject, time out or tear down anything.

---

**The only successful refresh was unproxied.** The macOS keychain item `Claude Code-credentials` was rewritten at `2026-09-02 11:56:29Z`, with no `/login` that day, so that write is a successful refresh. It did not go through the proxy:

- last proxied `platform.claude.com` `CONNECT`: 11:55:45, none after
- all proxy traffic ceased after 11:56:07; nothing at all 11:57–12:00; resumed 12:01
- `HTTPS_PROXY` was manually removed from `~/.claude/settings.json` at ~11:56:12 and restored ~12:01

The refresh succeeded direct, inside that five-minute gap. **Every refresh that actually traversed the proxy failed.**

---

**The differential.** Same process, same proxy, same minutes:

| Host | Client stack | Result |
|---|---|---|
| `api.anthropic.com` | `fetch` (main API client) | works |
| `platform.claude.com` | axios + https-proxy-agent (OAuth) | `ERR_SOCKET_CLOSED` after 15.4 s |

Per strings in the shipped binary the token exchange uses **axios + https-proxy-agent** while the main API client uses `fetch`. That split lines up exactly with what fails and what doesn't — the same shape as #71796, where the preflight used a different client (`Mo.get`) from the SDK.

---

**Ruled out**

- **Proxy ALPN / HTTP2.** The proxy originally advertised `h2`; the client negotiated HTTP/2 to the proxy and then sent an HTTP/1.1 `CONNECT`, which the proxy dropped. Fixed with `-disable-http2` *before* any of the evidence above was collected. The proxy now negotiates `http/1.1` when offered `h2,http/1.1`.
- **Proxy authentication.** All `CONNECT`s in the window log as the authenticated user, not as a 407.
- **TLS interception / IP blocking.** The genuine origin certificate chain comes through; the endpoint answers normally.
- **The libraries in isolation.** Under Bun 1.4.0, against this same proxy and against locally-hosted `http://` and `https://` CONNECT proxies with and without Basic auth, all of these succeed: `fetch`; axios + https-proxy-agent; raw `tls.connect({socket})` including TLS-in-TLS. Standalone `curl`, Node and Bun all reach the token endpoint through the proxy in 0.7–1.8 s and get a normal `400` for a dummy grant.

So neither the proxy nor the libraries reproduce this on their own — only the product does.

---

**Hypothesis.** `"Socket is closed"` is the error string Bun's `node:tls` compatibility layer raises when writing to a `tls.connect({ socket })` wrapper whose native handle is gone — i.e. the TLS-over-an-existing-socket path a CONNECT proxy agent uses, and that a direct connection never touches. Something about how the proxy agent is constructed, reused or torn down on the OAuth refresh path appears to leave that socket dead, after which the request hangs to its ~15 s timeout.

---

**Questions**

1. Did the 2.1.222 change cover the OAuth token endpoint, or only the two preflight probes?
2. Is the ~15 s timeout on the refresh request configurable?
3. Would a `--debug` log covering a failing refresh help? It is capturable — the failure recurs on its own and timestamps itself in telemetry — but the refresh happens on the product's schedule, so it takes a long-running debug session to catch. Happy to collect one if useful.

**Not yet collected:** a `--debug` log spanning an actual failing refresh. Everything above comes from telemetry, the proxy's own logs, the keychain item's modification time, and strings in the shipped binary.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start from the OAuth token refresh path for POST https://platform.claude.com/v1/oauth/token and compare its axios + https-proxy-agent transport with the fetch-based API path that succeeds. Reproduce with HTTPS_PROXY and inspect ~/.claude/telemetry/1p_failed_events.*.json for ERR_SOCKET_CLOSED failures. Done means silent refresh succeeds through an https:// CONNECT proxy without repeated failures.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, javascript, node.js
Domain
api, authentication, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.