ai newsSo., 25. Okt.
EilmeldungGive Claude a read-only straw to your SQLite DB: a tiny MCP server in Python

Leere Antwort trotz HTTP 200: Wenn Thinking-Tokens das max_tokens-Budget fressen

Eine der verwirrendsten Claude-API-Störungen: Der Request liefert Status 200, aber einen leeren Textblock, und der JSON-Parser meldet keinen Fund. Ursache im konkreten Fall waren 4.474 von 5.369 Output-Tokens, die als Thinking verbraucht wurden und das Token-Budget aufzehrten.

dev.toSonntag 06:00 · 25.10.DE4 Min. Lesezeit
Bild: dev.to

Veröffentlicht am 25. Oktober • Zuerst erschienen auf my-blog.org

Der verwirrendste Fehler der Claude-API ist kein 400, kein 429 und kein 529. Es ist ein HTTP 200 mit einem leeren Textblock. Es wurde nichts geworfen und nichts als Fehler protokolliert, und dennoch meldet Ihr JSON-Parser "kein JSON gefunden". In unserem Fall lag die Ursache darin, dass Thinking-Tokens das gesamte max_tokens-Budget aufzehrten: 4.474 von 5.369 Ausgabe-Tokens gingen für das Thinking drauf, sodass 895 für die Antwort übrig blieben.

Dieser Beitrag ist die Triage-Reihenfolge, die wir inzwischen verwenden. Er unterscheidet zwischen "wiederholen", "Anfrage korrigieren" und "warten", und er findet die Fehler, die überhaupt nie einen Fehler erzeugen. Die ausführlichere Fassung mit der vollständigen Statustabelle findet sich im Originalbeitrag.

Jeder API-Fehler kommt als dasselbe JSON-Objekt zurück: ein Fehlerobjekt auf oberster Ebene mit einem type und einer message, dazu eine request_id.

{ "type": "error", "error": { "type": "rate_limit_error", "message": "..." }, "request_id": "req_..." } Zweigen Sie anhand von error.type ab, niemals anhand des Nachrichtentexts. Die Dokumentation besagt, dass sich die Formulierung ändern kann, und das SDK wirft je nach Statuscode eine unterschiedliche Exception-Klasse, fangen Sie also die Klasse. Ein String-Vergleich auf der Nachricht wird eines Tages stillschweigend brechen.

Noch ein Detail: Das SDK wiederholt bereits Verbindungsfehler, 429er und 5xx-Antworten zweimal mit exponentiellem Backoff und beachtet dabei retry-after. Wenn eine Exception Ihren Code erreicht hat, sind bereits zwei weitere Versuche erfolgt.

Ein 429 kann ein Rate Limit sein, ein monatliches Ausgabenlimit für Ihre Nutzungsstufe oder ein Ausgabenlimit eines Claude-Code-Workspaces. Nur das erste verschwindet, wenn Sie warten.

Das Erkennungsmerkmal ist der retry-after-Header. Ein Rate Limit sendet ihn. Ein Ausgabenlimit nicht, und es schlägt weiter fehl, bis der Zugang wieder geöffnet wird. Wenn Sie ein Ausgabenlimit als Rate Limit behandeln und Backoff einbauen, erhalten Sie eine Schleife, die den ganzen Tag dasselbe 429 bekommt.

import anthropic def classify_429 ( err: anthropic. RateLimitError ) -> str: ra = err. response. headers. get ( " retry-after " ) if ra is None: return " cap-type: stop retrying, check spend caps and workspace limits " return f " rate-type: retry after { ra } s " Selten kann ein plötzlicher Anstieg der Organisationsnutzung ein 429 durch ein Beschleunigungslimit auslösen. Die Lösung besteht darin, den Verkehr allmählich hochzufahren und ein gleichmäßiges Nutzungsmuster beizubehalten.

Wenn die Payload in Ordnung aussieht, prüfen Sie, ob das Modell einen Parameter fallen gelassen hat. Dies sind die Meldungen, die für uns relevant waren:

Eine Beobachtung, die nicht in der offiziellen Fehlerdokumentation steht: Unser LLM-Wrapper enthält einen Kommentar, der angibt, keine Temperatur zu übergeben, zurückgeblieben nach einem alten Beispiel, das eine 400 auslöste, als es enthalten war. Betrachten Sie das als Erfahrung unserer Umgebung, nicht als dokumentierte Regel.

Thinking-Tokens zählen zu max_tokens. Wenn das Thinking das Budget zuerst verbraucht, endet die Antwort mit stop_reason: "max_tokens", und der Textblock ist abgeschnitten oder fehlt. Der Status ist 200, und es wird keine Exception ausgelöst.

Unser Fall: Ein Aufruf, der einen Content-Plan als JSON anforderte, lieferte einen leeren String zurück und schlug mit "JSON nicht gefunden" fehl. Anschließend durchliefen wir eine vierstufige Fehldiagnose:

Der letzte Punkt ist das Unterscheidungsmerkmal. Hätte der Prompt das Modell eher in Richtung Prosa statt JSON gedrängt, wäre die rohe Antwort nicht leer gewesen. Nur ein leerer Rohstring stützt die Diagnose "Thinking hat das Budget verbraucht".

def diagnose_empty ( resp ) -> str | None: text = "". join ( b. text for b in resp. content if b. type == " text " ) if text. strip (): return None thinking = getattr ( getattr ( resp. usage, " output_tokens_details ", None ), " thinking_tokens ", None ) return ( f " empty response: stop_reason= { resp. stop_reason }, " f " output= { resp. usage. output_tokens }, thinking= { thinking } " ) Das zu lesende Feld ist usage.output_tokens_details.thinking_tokens. Beim Streaming erscheint es nur im finalen message_delta-Ereignis.

Es gibt zwei offizielle Lösungen: max_tokens erhöhen, damit Thinking und Text beide hineinpassen, oder den Effort senken, damit das Thinking weniger verbraucht. Wir haben eine dritte hinzugefügt. Wir haben reduziert, was ein einzelner Aufruf liefern muss: Statt "N Beiträge auf einmal planen" fragen wir zunächst eine Gliederung an, dann pro Beitrag ein Inhaltsverzeichnis. Der Aufruf, der dreimal fehlgeschlagen war, funktionierte beim ersten Versuch.

Die Faustregel: Muss die Ausgabe als ein Stück ankommen, verwenden Sie die offiziellen Lösungen. Lässt sie sich aufteilen, dann teilen Sie sie auf.

Wenn ein Claude-Code-MCP-Tool nur beim ersten Aufruf in einer Sitzung fehlschlägt, verdächtigen Sie einen Cold Start. Einer unserer Search-Console-Server läuft beim ersten Aufruf in einen Timeout und funktioniert erst, wenn ein leichtgewichtiges Tool ihn aufgeweckt hat.

Der wichtigste Warnhinweis: Wiederholen Sie niemals einen abgelaufenen Schreibvorgang. Ein Timeout bedeutet "Ergebnis unbekannt", nicht "fehlgeschlagen". Das erneute Senden eines Schreibvorgangs, der bereits angekommen ist, wendet die Änderung zweimal an, prüfen Sie also zuerst, ob sie wirksam wurde.

Zwei Dinge deckt diese Tabelle nicht ab. Die Meldungen zu Abo-Limits in der Claude-Code-Oberfläche, etwa "You've hit your session limit", sind Plan-Hinweise, keine HTTP-Fehler. Und jede Automatisierung, die "Erfolg" meldet, während sie nichts produziert, gehört zur selben Familie wie die leere Antwort.

Für die vollständige Statustabelle, die FAQ und die zugehörigen Beiträge dazu sehen Sie sich den kompletten Leitfaden auf my-blog.org an.

Kostenlose Tools, die ich auf meiner eigenen Seite bereit halte: 34 Rechner und Anleitungen, zu Krediten, Abfindung, Nettogehalt, Sparen versus Einlagen. Neue erscheinen in einer E-Mail, ohne Werbung.

Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.

Original (Englisch) anzeigen

Posted on Oct 25 • Originally published at my-blog.org

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.

This 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.

Every API error comes back as the same JSON object: a top-level error with a type and a message, plus a request_id.

{ "type": "error", "error": { "type": "rate_limit_error", "message": "..." }, "request_id": "req_..." } Enter fullscreen mode Exit fullscreen mode Branch 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.

One 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.

A 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.

The 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.

import anthropic def classify_429 ( err: anthropic. RateLimitError ) -> str: ra = err. response. headers. get ( " retry-after " ) if ra is None: return " cap-type: stop retrying, check spend caps and workspace limits " return f " rate-type: retry after { ra } s " Enter fullscreen mode Exit fullscreen mode Rarely, 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.

When the payload looks fine, check whether the model dropped a parameter. These are the messages that mattered to us:

One 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.

Thinking 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.

Our 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:

That 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.

def diagnose_empty ( resp ) -> str | None: text = "". join ( b. text for b in resp. content if b. type == " text " ) if text. strip (): return None thinking = getattr ( getattr ( resp. usage, " output_tokens_details ", None ), " thinking_tokens ", None ) return ( f " empty response: stop_reason= { resp. stop_reason }, " f " output= { resp. usage. output_tokens }, thinking= { thinking } " ) Enter fullscreen mode Exit fullscreen mode The field to read is usage.output_tokens_details.thinking_tokens. When streaming, it only appears on the final message_delta event.

There 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.

The rule of thumb: if the output must arrive in one piece, use the official fixes. If it can be split, split it.

If 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.

The 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.

Two 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.

For the full status table, the FAQ, and the related posts on this, see the complete guide on my-blog.org.

Free tools I keep on my own site: 34 calculators and how-to guides - loans, severance pay, take-home salary, savings vs deposits. New ones go out in one email, no ads.

Originaltitel: 4,474 of 5,369 Output Tokens Were Thinking: Reading Claude API ErrorsQuelle: dev.to (dev.to). Original öffnen