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
- Exhaust the Claude subscription rate limit for the account the
claude CLI is authenticated as.
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
Problem
The
claudeCLI reports API-level failures (rate limits, auth) in the stdout JSON envelope withis_error: true, leaving stderr empty. Depending on the failure it exits either non-zero or zero.Both
claude -pcall sites ingraphify/llm.pydecide success fromproc.returncodeand read the cause only fromproc.stderr, so three distinct failures go unreported.Direct observation of what the CLI actually returns on a rate limit:
Note
is_error: truealongsidesubtype: "success"and exit code0.Case A: non-zero exit, cause discarded
graphify/llm.py:1541(extraction) andgraphify/llm.py:2600(_call_llm) build the message fromproc.stderralone. stderr is empty, so the user gets a colon with nothing after it: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: trueburns retries during the outageWhen the CLI exits 0, the
returncodecheck passes andenvelope["result"](the string"API Error: Rate limit reached") is handed to_parse_llm_jsonas if it were model output. It parses to nothing, so_response_is_hollowatgraphify/llm.py:1571reads it as truncation and forcesfinish_reason = "length":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_llmends withreturn 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 reachedand be persisted intograph.json. Unlike A and B this leaves bad data behind after the outage ends.Reproduction
claudeCLI is authenticated as.graphify extract <corpus> --backend claude-cliObserved: 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_errorflag rather than inferring failure from the exit code alone:resulttext whenis_erroris set, and an empty string otherwise (including when stdout will not parse, so the existing "unparseable JSON envelope" error still wins).stderrand fall back to the envelope text. stderr should keep priority so a genuine CLI crash or spawn failure still reports its own diagnostics.Failing fast on a rate limit is the intended behaviour:
extract_corpus_parallelalready 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
v8)-p --output-format json