Skip to content

claude-cli: API errors in the stdout envelope go unreported, so a rate limit prints a blank exited 1:, keeps retrying against the outage, and can land in graph.json as a community label #2554

Description

@annieyii

Problem

The claude CLI reports API-level failures (rate limits, auth) in the stdout JSON envelope with is_error: true, leaving stderr empty. Depending on the failure it exits either non-zero or zero.

Both claude -p call sites in graphify/llm.py decide success from proc.returncode and read the cause only from proc.stderr, so three distinct failures go unreported.

Direct observation of what the CLI actually returns on a rate limit:

$ echo "say OK" | claude -p --output-format json
{"type":"result","subtype":"success","is_error":true,"duration_ms":496,
 "result":"API Error: Rate limit reached","stop_reason":"stop_sequence", ...}
$ echo $?
0

Note is_error: true alongside subtype: "success" and exit code 0.

Case A: non-zero exit, cause discarded

graphify/llm.py:1541 (extraction) and graphify/llm.py:2600 (_call_llm) build the message from proc.stderr alone. stderr is empty, so the user gets a colon with nothing after it:

[graphify] chunk 1/11 failed: claude -p exited 1:
[graphify] chunk 2/11 failed: claude -p exited 1:
...
[graphify] chunk 11/11 failed: claude -p exited 1:
[graphify extract] error: all semantic chunks failed for backend 'claude-cli' (309 uncached files)

Eleven identical blank errors. Nothing indicates the account was rate limited rather than, say, the CLI being misconfigured.

Case B: zero exit with is_error: true burns retries during the outage

When the CLI exits 0, the returncode check passes and envelope["result"] (the string "API Error: Rate limit reached") is handed to _parse_llm_json as if it were model output. It parses to nothing, so _response_is_hollow at graphify/llm.py:1571 reads it as truncation and forces finish_reason = "length":

[graphify] LLM returned invalid JSON, skipping chunk (first 200 chars: 'API Error: Rate limit reached')
[graphify] claude-cli returned a hollow response; treating as truncation so adaptive retry can bisect the chunk.

Adaptive retry then bisects the chunk and re-issues requests that are still being refused. A rate limit is the one condition where retrying cannot help, and it is exactly the condition that triggers the retry path.

Case C: the error string is written into the graph (data corruption)

_call_llm ends with return envelope.get("result", "") (graphify/llm.py:2611). Its callers are the dedup LLM tiebreaker (graphify/dedup.py:821) and community labeling (graphify/llm.py:2952).

Neither validates the reply, so during a rate limit a community label can become the literal string API Error: Rate limit reached and be persisted into graph.json. Unlike A and B this leaves bad data behind after the outage ends.

Reproduction

  1. Exhaust the Claude subscription rate limit for the account the claude CLI is authenticated as.
  2. graphify extract <corpus> --backend claude-cli

Observed: eleven claude -p exited 1: lines with no cause (Case A). With the same account in the zero-exit variant, the hollow-response retry path engages instead (Case B).

Proposed fix

I could not find an existing issue covering this. There is an unmerged PR that partially addresses Case A; Cases B and C both occur on a zero exit code and are outside its scope.

Read the envelope's is_error flag rather than inferring failure from the exit code alone:

  • A small helper parses stdout and returns the envelope's result text when is_error is set, and an empty string otherwise (including when stdout will not parse, so the existing "unparseable JSON envelope" error still wins).
  • On a non-zero exit, prefer stderr and fall back to the envelope text. stderr should keep priority so a genuine CLI crash or spawn failure still reports its own diagnostics.
  • On a zero exit with the flag set, raise instead of treating the error text as model output. This fixes B and C together, since both stem from that string being passed downstream.

Failing fast on a rate limit is the intended behaviour: extract_corpus_parallel already catches per-chunk exceptions and continues, so each chunk reports the real cause instead of silently bisecting.

I have this implemented with tests and can open a PR if the approach looks right.

Environment

  • graphify 0.9.36 (v8)
  • Claude Code CLI, -p --output-format json
  • macOS 15 (Darwin 25.5.0), Python 3.10

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions