{"slug": "4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors", "title": "4,474 of 5,369 Output Tokens Were Thinking: Reading Claude API Errors", "summary": "A developer documented a triage approach for Claude API failures that surface as HTTP 200 responses with empty text blocks, tracing one case where 4,474 of 5,369 output tokens were consumed by thinking, leaving only 895 for the answer. The writeup recommends branching on the error.type field rather than message text, distinguishing 429 rate limits from spend caps by the presence of a retry-after header, and checking stop_reason for max_tokens truncation. It also catalogs parameter rejections on newer models, including assistant message prefill, fixed budget_tokens thinking, modified thinking blocks, and forced tool_choice.", "body_md": "The most confusing Claude API failure is not a 400, a 429, or a 529. It is an HTTP 200 with an empty text block. Nothing was thrown and nothing was logged as an error, yet your JSON parser reports \"no JSON found\". In our case the cause was thinking tokens eating the whole `max_tokens` budget: 4,474 of 5,369 output tokens went to thinking, which left 895 for the answer.\n\nThis post is the triage order we use now. It separates \"retry\", \"fix the request\", and \"wait\", and it catches the failures that never produce an error at all. The longer write-up, with the full status table, is on [the original post](https://my-blog.org/claude-code/post/claude-api-errors-empty-response?utm_source=devto&utm_medium=referral&utm_campaign=claude-api-errors-empty-response).\n\nEvery API error comes back as the same JSON object: a top-level `error` with a `type` and a `message`, plus a `request_id`.\n\n```\n{\n  \"type\": \"error\",\n  \"error\": {\n    \"type\": \"rate_limit_error\",\n    \"message\": \"...\"\n  },\n  \"request_id\": \"req_...\"\n}\n```\n\nBranch on `error.type`, never on the message text. The docs say wording can change, and the SDK raises a different exception class per status code, so catch the class. A string comparison on the message will break silently one day.\n\nThe first-pass reading:\n\n`invalid_request_error`): the request is wrong. Retrying the same request gives the same result.`rate_limit_error`): a limit of some kind. Which kind matters, see the next section.`overloaded_error`): temporary overload across all users. Wait and retry. It is not an account problem.`api_error`) and 504 (` timeout_error`): server-side. Back off, or move long requests to streaming or the batch API.\nOne more detail: the SDK already retries connection errors, 429s, and 5xx responses twice with exponential backoff, honoring `retry-after`. If an exception reached your code, two more attempts already happened.\n\nA 429 can be a rate limit, a monthly spend cap for your usage tier, or a Claude Code workspace spend limit. Only the first one clears if you wait.\n\nThe tell is the `retry-after` header. A rate limit sends it. A spend cap does not, and it keeps failing until access reopens. If you treat a spend cap as a rate limit and add backoff, you get a loop that receives the same 429 all day.\n\n``` php\nimport anthropic\n\ndef classify_429(err: anthropic.RateLimitError) -> str:\n    ra = err.response.headers.get(\"retry-after\")\n    if ra is None:\n        return \"cap-type: stop retrying, check spend caps and workspace limits\"\n    return f\"rate-type: retry after {ra}s\"\n```\n\nRarely, a sudden jump in organization usage can trigger a 429 from an acceleration limit. The fix there is to ramp traffic up gradually and keep usage patterns even.\n\nWhen the payload looks fine, check whether the model dropped a parameter. These are the messages that mattered to us:\n\n| Message fragment | Cause | Fix | \n|---|---|---|\n| \"does not support assistant message prefill\" | Newer models reject a prefilled last assistant message | Use structured output or the system prompt | \n| `\"thinking.type.enabled\" is not supported` | Fixed `budget_tokens` thinking was removed on newer models | `thinking: {type: \"adaptive\"}` plus`effort` | \n| \"thinking blocks ... cannot be modified\" | You edited or dropped the previous assistant turn's thinking blocks | Send them back unchanged | \n| `tool_choice: type \"tool\" and \"any\" are not supported` | Some models reject forced tool calls | `auto` plus strict tool use | \n\nOne observation that is not in the official error docs: our LLM wrapper has a comment saying not to pass `temperature`, left behind after an old example that included it returned a 400. Treat that as our environment's experience, not a documented rule.\n\nThinking tokens count toward `max_tokens`. If thinking spends the budget first, the response ends with `stop_reason: \"max_tokens\"` and the text block is truncated or missing. The status is 200 and no exception is raised.\n\nOur case: a call asking for a content plan as JSON returned an empty string and failed with \"JSON not found\". We then walked a four-step misdiagnosis:\n\nThat last point is the discriminator. If the prompt had pushed the model toward prose instead of JSON, the raw response would not be empty. Only an empty raw string supports the \"thinking used the budget\" diagnosis.\n\n``` php\ndef diagnose_empty(resp) -> str | None:\n    text = \"\".join(b.text for b in resp.content if b.type == \"text\")\n    if text.strip():\n        return None\n    thinking = getattr(getattr(resp.usage, \"output_tokens_details\", None),\n                       \"thinking_tokens\", None)\n    return (f\"empty response: stop_reason={resp.stop_reason}, \"\n            f\"output={resp.usage.output_tokens}, thinking={thinking}\")\n```\n\nThe field to read is `usage.output_tokens_details.thinking_tokens`. When streaming, it only appears on the final `message_delta` event.\n\nThere are two official fixes: raise `max_tokens` so thinking and text both fit, or lower `effort` so thinking spends less. We added a third. We cut what a single call has to produce: instead of \"plan N posts at once\", we ask for one outline, then one table of contents per post. The call that had failed three times passed on the first try.\n\nThe rule of thumb: if the output must arrive in one piece, use the official fixes. If it can be split, split it.\n\n`stop_reason` and `thinking_tokens`.` thinking.type`, `tool_choice`). Do not retry unchanged.` retry-after` present means rate-type; absent means cap-type, so stop the loop.`request_id` if it persists. Give that ID to support.`/mcp`, and if only the first call fails, raise `MCP_TIMEOUT`.\nIf a Claude Code MCP tool fails only on its first call in a session, suspect a cold start. One of our search-console servers times out on the first call and works once a lightweight tool has woken it.\n\n| Control | What it adjusts | Example | \n|---|---|---|\n| `MCP_TIMEOUT` env var | Server startup wait | `MCP_TIMEOUT=10000 claude` | \n| Per-server `timeout` in`.mcp.json` | Tool execution time in ms | `\"timeout\": 600000` | \n| `MAX_MCP_OUTPUT_TOKENS` | Tool output token cap | Default 25,000 | \n\nThe caution that matters most: do not retry a timed-out write. A timeout means \"result unknown\", not \"failed\". Resending a write that already landed applies the change twice, so check whether it took effect first.\n\nTwo things this table does not cover. The subscription limit messages in the Claude Code UI, such as \"You've hit your session limit\", are plan notices, not HTTP errors. And any automation that reports \"success\" while producing nothing belongs to the same family as the empty response.\n\nFor the full status table, the FAQ, and the related posts on this, see [the complete guide on my-blog.org](https://my-blog.org/claude-code/post/claude-api-errors-empty-response?utm_source=devto&utm_medium=referral&utm_campaign=claude-api-errors-empty-response).\n\n*Free tools I keep on my own site: [34 calculators and how-to guides](https://my-blog.org/tools?utm_source=devto&utm_medium=referral&utm_campaign=claude-api-errors-empty-response) - loans, severance pay, take-home salary, savings vs deposits. New ones go out in one email, no ads.*", "url": "https://wpnews.pro/news/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors", "canonical_source": "https://dev.to/sungwoo_lee_e0f26be4a29fd/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors-490h", "published_at": "2026-10-25 05:00:22+00:00", "updated_at": "2026-10-25 05:14:37.247611+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "developer-tools", "ai-agents"], "entities": ["Anthropic", "Claude", "Claude Code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors", "markdown": "https://wpnews.pro/news/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors.md", "text": "https://wpnews.pro/news/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors.txt", "jsonld": "https://wpnews.pro/news/4474-of-5369-output-tokens-were-thinking-reading-claude-api-errors.jsonld"}}