●SUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, now two days away●LATEST — Version 2.1.231, released August 13, is current; it fixes MCP OAuth sign-in on servers that use a pre-registered client●RESUME — Version 2.1.229, released August 12, adds `claude remote-control --continue` for picking up your most recent Remote Control session●STREAM — SSE keepalive pings now prevent gateway streaming from dropping on idle timeout during long thinking pauses, which helps Vertex and Bedrock setups●SELFHOST — Version 2.1.224, released August 7, introduces the `claude self-hosted-runner` command for self-hosted environments●MCP — Monthly MCP SDK downloads have passed 400M, a fourfold jump this year, with over 950 servers listed in the connectors directory●SUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, now two days away●LATEST — Version 2.1.231, released August 13, is current; it fixes MCP OAuth sign-in on servers that use a pre-registered client●RESUME — Version 2.1.229, released August 12, adds `claude remote-control --continue` for picking up your most recent Remote Control session●STREAM — SSE keepalive pings now prevent gateway streaming from dropping on idle timeout during long thinking pauses, which helps Vertex and Bedrock setups●SELFHOST — Version 2.1.224, released August 7, introduces the `claude self-hosted-runner` command for self-hosted environments●MCP — Monthly MCP SDK downloads have passed 400M, a fourfold jump this year, with over 950 servers listed in the connectors directory
The Connection That Dies Mid-Thought Is Being Killed by Your Relay, Not the Upstream
Put a proxy or Worker in front of Claude Code and long thinking pauses start dying. The cause is neither the model nor the upstream — it is your relay holding the byte stream. Six relay behaviors compared side by side, plus how to verify yours before it ships.
Short prompts sail through every time. The ones that make the model think die partway, and the upstream logs show nothing at all.
If you have a relay of your own sitting in the request path, you may have met this shape of failure. I did, shortly after writing a small relay that runs at the edge. My first suspicion was instability on the model side. That was wrong. The thing cutting the connection was the handful of lines I had written myself.
What makes it awkward is that the fault only shows up on heavy work. Smoke tests use short prompts, so everything passes, and the first real failure arrives on the day someone asks for something hard.
The conclusion, up front
The disconnect is not triggered by a failed response. It is triggered by no bytes arriving for a while.
For connections through ANTHROPIC_BASE_URL or ANTHROPIC_AWS_BASE_URL, Claude Code counts every byte your gateway relays. SSE ping events and comment lines all count, and a stream that goes completely silent for 300 seconds is aborted by default. This is written into the official gateway protocol reference.
During a long thinking pause, the upstream's keep-alive pings are the only traffic on the wire. So the moment your relay drops them, or holds them to flush later in a batch, the line goes silent as far as the client is concerned — while the upstream is still talking perfectly happily.
Version 2.1.229, released on August 12, 2026, added SSE keepalive pings to gateway streaming responses during long thinking pauses, which addressed idle-timeout disconnects on Vertex and Bedrock upstreams. The same release fixed a case where the idle timeout fired on custom ANTHROPIC_BASE_URL gateways even though keep-alive pings were demonstrably arriving on the wire. The upstream side has been improved. What remains is the relay you wrote.
The watchdog counts bytes, not meaning
This is where I first went wrong. I had assumed the client was checking whether it had received a ping event. It is far more primitive than that: it only looks at how many bytes arrived.
That primitiveness cuts both ways. Anything at all flowing resets the clock, even if it is not shaped like a ping. And a perfectly correct ping resets nothing if your relay is holding it.
Worse, the monitoring mechanism itself swaps out depending on which environment variable you connect with. Confuse these and you will harden a path that was never the one failing.
Variable used to connect
Monitoring mechanism
Default on silence
ANTHROPIC_BASE_URL / ANTHROPIC_AWS_BASE_URL
Byte-level watchdog (comment lines count)
Abort after 300 seconds
ANTHROPIC_BEDROCK_BASE_URL
Not wrapped by the watchdog. Can be added with CLAUDE_ENABLE_BYTE_WATCHDOG_BEDROCK
The trap here is that a gateway can speak the Anthropic Messages format while the client connects through ANTHROPIC_BEDROCK_BASE_URL, in which case the byte-level watchdog does not wrap it. "Same format, therefore same behavior" does not hold. When someone brings me a disconnect, the first thing I ask is not the model name or the region — it is which variable you are connecting with.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to tell whether a mid-thought disconnect came from the upstream, the relay, or the client without adding a single new log line
✦You will be able to check whether your own proxy or Worker is hoarding the stream before it reaches production instead of after
✦You will be able to tell which connection paths the 300-second byte watchdog actually guards, because the environment variable you connect with changes the mechanism entirely
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Writing mitigations from guesswork leaves you with nothing to learn from when they do not work. So I built a minimal rig with the upstream, the relay, and the client separated, and swapped out only the relay implementation.
Real-world seconds make this tedious to test, so the timings are scaled down while preserving the ratio. The upstream thinks for 6 seconds; the client's silence limit is 4 seconds. That is roughly one-fiftieth of the production 300-second scale. A 1.5-second ping interval against a 4-second silence limit leaves about 2.7x of headroom; scaled back up, that means a ping interval under 110 seconds is enough in production.
The upstream sends one frame, then falls quiet and emits nothing but comment lines at a set interval.
// origin.mjs — the simulated upstream: one frame, then a long think with pings onlyimport http from 'node:http';const SILENCE = Number(process.env.SILENCE ?? 6000); // length of the thinking pauseconst PING = Number(process.env.PING_MS ?? 0); // 0 disables pingshttp.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.write('event: message_start\ndata: {"type":"message_start"}\n\n'); // The thinking pause. No content, only comment lines. const iv = PING ? setInterval(() => res.write(': ping\n\n'), PING) : null; setTimeout(() => { if (iv) clearInterval(iv); res.write('event: content_block_delta\ndata: {"text":"done"}\n\n'); res.write('event: message_stop\ndata: {}\n\n'); res.end(); }, SILENCE);}).listen(4001);
The client is a watchdog that does nothing but rearm a timer whenever bytes arrive. Its simplicity is the whole explanation of the behavior.
// client.mjs — any byte at all rewinds the clockconst IDLE = Number(process.env.IDLE ?? 4000);const t0 = Date.now();const ctrl = new AbortController();let timer = setTimeout(() => ctrl.abort(), IDLE);let bytes = 0;let wakeups = 0;try { const r = await fetch('http://127.0.0.1:4002/v1/messages', { signal: ctrl.signal }); const reader = r.body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; bytes += value.length; wakeups++; clearTimeout(timer); // it never inspects the content timer = setTimeout(() => ctrl.abort(), IDLE); } clearTimeout(timer); console.log(`OK elapsed=${Date.now() - t0}ms bytes=${bytes} wakeups=${wakeups}`);} catch (e) { clearTimeout(timer); console.log(`ABORT elapsed=${Date.now() - t0}ms bytes=${bytes} wakeups=${wakeups}`);}
The relay can switch between five behaviors: stream passes bytes through, buffer reads everything before replying, strip removes whole ping frames, strip-loose removes only the ping lines and leaves the blank lines, and aggregate flushes in five-second batches.
// proxy.mjs — swap MODE to compare behaviorsimport http from 'node:http';const MODE = process.env.MODE ?? 'stream';const FLUSH = Number(process.env.FLUSH ?? 5000);http.createServer(async (req, res) => { const up = await fetch('http://127.0.0.1:4001/v1/messages'); res.writeHead(200, { 'Content-Type': 'text/event-stream' }); // The one line everybody writes. Everything piles up here. if (MODE === 'buffer') { res.end(await up.text()); return; } const reader = up.body.getReader(); const dec = new TextDecoder(); let pending = ''; const iv = MODE === 'aggregate' ? setInterval(() => { if (pending) { res.write(pending); pending = ''; } }, FLUSH) : null; for (;;) { const { done, value } = await reader.read(); if (done) break; let chunk = dec.decode(value, { stream: true }); if (MODE === 'strip') chunk = chunk.replace(/^:[^\n]*\n\n/gm, ''); // whole frames if (MODE === 'strip-loose') chunk = chunk.split('\n').filter((l) => !l.startsWith(':')).join('\n'); if (!chunk.length) continue; if (MODE === 'aggregate') pending += chunk; else res.write(chunk); } if (iv) clearInterval(iv); if (pending) res.write(pending); res.end();}).listen(4002);
Same upstream, same client, relay swapped:
Relay behavior
Upstream pings
Result
Elapsed
Bytes reaching client
Pass through
none
Aborted
4.06s
53
Pass through
every 1.5s
Completed
6.05s
157
Read fully, then reply
every 1.5s
Aborted
4.00s
0
Strip whole ping frames
every 1.5s
Aborted
4.06s
53
Strip ping lines, keep blank lines
every 1.5s
Completed
6.05s
136
Flush every 5 seconds
every 1.5s
Aborted
4.00s
0
Row five is the one that went against my expectation. It survived while deleting the pings.
The reason is unglamorous. A line-based filter removes the : ping line but not the blank line that terminates the frame, so two newline bytes keep trickling through. The watchdog only counts bytes, so that is enough. The ping is semantically gone; the monitoring is satisfied anyway.
Together these rows show that "forward the pings and you're safe" is wrong in two directions at once. There are paths that survive without pings, and paths that die with them. What matters is not whether pings exist but whether a silent interval was created.
Note also that two of the three aborted cases delivered zero bytes to the client, because the relay was holding all of them. The dangerous part is what that does to your logs: no error upstream, no exception in the relay, and the fact of the disconnect recorded only on the client. That is a textbook recipe for a bug you cannot find by cross-referencing three log streams.
The one line that breaks it at the edge
As an indie developer I run several sites on Cloudflare Workers, so having a Worker relay a response is an everyday shape for me. What that taught me is how naturally the stream-breaking version writes itself.
// Before — works fine, until the day someone asks a hard questionexport default { async fetch(request, env) { const upstream = await fetch(env.UPSTREAM, request); const body = await upstream.text(); // waits for all of it right here return new Response(body, { status: upstream.status, headers: upstream.headers, }); },};
await upstream.text() is exactly the line your hand reaches for when you want to add logging, or branch on something in the payload. And the response still comes back correctly, so short prompts reveal nothing.
// After — never touch the body, just hand the stream alongexport default { async fetch(request, env) { const upstream = await fetch(env.UPSTREAM, request); // Do not consume the body. Pass the ReadableStream straight through. const headers = new Headers(upstream.headers); headers.set('Cache-Control', 'no-cache, no-transform'); headers.set('X-Accel-Buffering', 'no'); // ask upstream layers not to buffer headers.delete('Content-Length'); // a stream has no length return new Response(upstream.body, { status: upstream.status, headers, }); },};
no-transform and X-Accel-Buffering: no are statements of intent aimed at whatever sits in front of your Worker. Fixing your own relay does nothing if an nginx ahead of it is buffering under the default proxy_buffering on. If you own that nginx, set proxy_buffering off; and proxy_cache off; on the relevant location, and raise proxy_read_timeout above your longest expected thinking pause.
For deciding what to do in your own code, this order keeps it simple:
Ask whether you genuinely need to read the body. For most relays the answer is no, so default to passing upstream.body through untouched
If you truly must inspect it, use a TransformStream to peek while it flows. Do not accumulate
If any layer compresses, reformats, or inspects the response, confirm it emits frame by frame
The third is the one people miss. A compressing middlebox coalesces small writes because that is the efficient thing to do for content delivery. It is only harmful for streaming.
Do not break what happens after the cut
One more thing the test rig cannot show you, but production will.
Claude Code retries automatically after certain upstream rejections and disables the rejected capability for the rest of the conversation to recover. That retry decision is made by matching on the wording of the upstream's error body.
Which means a relay that helpfully wraps errors in its own envelope breaks the recovery path even when it faithfully preserves the status code. I understand the urge to normalize error shapes for monitoring, and in other contexts I would be the one recommending it. On this path, the right answer was to return error responses exactly as received.
The same logic applies to relays that rewrite request bodies for inspection. Headers and body fields travel as pairs, so dropping half of a pair produces a hard 400. If you must inspect, read without modifying.
This principle — that a relay must not dilute what the upstream is telling you — is the same one behind reading rate-limit headers and throttling ahead of exhaustion, which I wrote up in field notes on measuring rate-limit headroom from headers.
Verifying before it ships
Once you have changed your relay, you do not need to wait for a long thinking pause to test it. The rig above transfers to your own setup by adjusting SILENCE and IDLE to match your real ratio.
There is only one thing to confirm: while the upstream is quiet, are bytes still reaching the client? If yes, you are safe. If no, you will fail on the day that interval grows past the limit.
If you want a single action today, open your relay's source and search for a .text() or .json() after an await. If one is there, that is where your silent interval is being manufactured.
It took me several rounds of blaming the upstream and the model before I found that line. I hope this saves you the detour.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.