[BUG] API errors that kill a turn are rendered to the user but leave no trace for the model

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 1 comment · opened Aug 1, 2026

Preflight Checklist

  • [x] I have searched existing issues 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?

Summary

When a turn fails with an API error and the automatic retry is exhausted, the TUI shows the error to the user — but the model's next request contains no trace of it at all. On top of that, the user's follow-up message is merged into the previous user message, so the model cannot even tell that the user spoke twice.

From the model's seat, a turn that died is indistinguishable from a turn that never happened.

Reproduction

I isolated this from any provider by pointing Claude Code at a local mock Messages API server.

  1. Run a mock implementing POST /v1/messages that returns HTTP 400 for the first N requests (N ≥ 2 — with N = 1 the automatic retry succeeds and nothing is observable), then responds normally.
  2. Start Claude Code against it: ANTHROPIC_BASE_URL=http://localhost:8899 claude
  3. Type a. The turn dies and the TUI shows the API error.
  4. Type 続けて〜 (any "please continue" nudge).
  5. Compare what the TUI showed with the requests the mock received.

Observed

What the user saw (TUI):

a
● API Error: 400 Download multimodal file timed out
✳ Churned for 0s
  続けて〜
● API Error: 400 Download multimodal file timed out
✳ Cogitated for 0s

What the mock received:

#1  messages[0].role = user   content = ["a"]
#2  messages[0].role = user   content = ["a"]                ← automatic retry, byte-identical
#3  messages[0].role = user   content = ["a\n", "続けて〜"]   ← the two user inputs, merged

Three things are missing from request #3:

  • there is no assistant message (expected — the turn produced no output),
  • there is no marker of any kind saying the previous attempt failed,
  • and the follow-up did not become a new message — it was appended to the existing user message.

So the model receives one user message reading "a\n続けて〜". It has no way to know that a turn was attempted, that it failed, or that the human sent a second message at all.

Why this matters: a turn can end three ways, and only two are distinguishable

| how the turn ended | can the model tell? |
|---|---|
| the model finished its response | yes — it is the model's own output |
| the user interrupted it | yes — [Request interrupted by user] appears in the context |
| it was cut off by an API failure | no — nothing at all |

The retry machinery itself does not need to be exposed. Retrying a failed LLM call is normal plumbing, and surfacing every retry would be noise. But exhausting the retries changes what happened: the turn did not end, it was severed. That distinction is currently lost on the model's side, even though the other two endings are already communicated.

Impact

The user and the model end up with incompatible readings of the same message.

In a real session, a turn died this way and the user sent "please continue" as a crash nudge. The task the model had been given was already complete, so from the model's seat the message looked like a fresh instruction rather than a resume signal — it read it as "move on to the next task", started work that had not been approved, and the user had to interrupt it.

The same session contains the control case: on turns where the model could see that something had gone wrong (a tool call had returned an error it could read), the identical "please continue" was correctly interpreted as "recover and retry". The difference in interpretation tracks exactly with whether any evidence of the failure reached the model.

I consider this a bug rather than a missing convenience: the harness has the information (it rendered the error to the user), the model acts on the context it is given, and the gap between the two produces wrong actions.

Related

  • #83138 — the same asymmetry in the opposite direction: a tool error that is delivered to the model but never rendered to the user. Together they describe one problem — the user and the model are shown different sets of failures — but they are separate defects with separate fixes, so I filed them separately.
  • #79946 — asks for a way to resume an interrupted generation without adding a user turn. Orthogonal to this: implementing resume would not tell the model that a turn died (users would still send plain "continue" messages), and telling the model would not provide resume.

What Should Happen?

Two things, either of which helps on its own:

  1. When retries are exhausted and a turn is abandoned, put something in the model's context saying so. It does not need to describe the retries or the status code — just that the previous turn was cut off and produced no output. This is the same class of signal as the existing [Request interrupted by user].
  1. Do not merge a new user message into the previous one when the turn in between failed. Even without (1), keeping them as separate messages preserves the fact that the user spoke twice, which is itself evidence that something happened between them.

Error Messages/Logs

Mock server log for the reproduction above (N = 3):

=== request #1 ===  roles: user → system
  >>> returning HTTP 400 (1/3)
=== request #2 ===  roles: user → system
  >>> returning HTTP 400 (2/3)        <- automatic retry, identical to #1
=== request #3 ===  roles: user → system
  >>> returning HTTP 400 (3/3)        <- after the user typed the follow-up

Request #3, messages[0]:
  role: "user"
  content: [ {type: "text", text: "a\n"}, {type: "text", text: "続けて〜"} ]

Claude Code gave up after two attempts (initial + one retry) and rendered
"API Error: 400 ..." in the TUI, both times.

Platform

Other (local mock Messages API server — the behaviour is in the client and does not depend on the provider)

Operating System

Ubuntu/Debian Linux

Terminal/Shell

iTerm2

Additional Information

Claude Code version: 2.1.220

Note on retry visibility: with N = 1 (a single 400, so the retry succeeds) nothing is observable from either seat — no TUI error, no trace in the request. That is the correct behaviour for transient failures, and I am not asking for it to change. This report is only about the case where the retries run out.

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗