{"slug": "exit-0-empty-stdout-the-quota-died-on-stderr", "title": "Exit 0, Empty stdout: the Quota Died on stderr", "summary": "A developer discovered that OpenAI's Codex CLI and other subscription-gated command-line tools can silently fail by printing errors to stderr while exiting with code 0 and producing empty stdout, causing automation to record quota deaths as successful tasks. The developer now treats exit codes as weak signals and checks actual output before marking tasks complete, a lesson that generalizes to any pipeline-friendly tool.", "body_md": "*Originally published on hexisteme notes.*\n\nPart of how I run background coding tasks is by shelling out to a subscription-gated CLI from a different vendor than my main assistant, one task at a time. No daemon, no shared server — each worker starts, does one job, and exits, and I read back whatever it produced. That pattern itself is fine.\n\nWhat I got wrong for a while was how I decided whether a worker had actually done anything.\n\nOn 2026-08-07, one of these workers — OpenAI's Codex CLI, invoked non-interactively — hit its own usage cap mid-task. The real failure message was:\n\n```\nERROR: You've hit your usage limit\n```\n\nprinted to **stderr**. Meanwhile:\n\nIf a dispatcher only checks `$?`\n\n, this looks identical to a worker that quietly finished a trivial task and had nothing to say. There's no crash, no nonzero status, no exception to catch anywhere in the normal control flow. The failure is completely real; it's just filed under the wrong file descriptor, and the exit code actively lies about it.\n\nA tool that fails loudly — nonzero exit, a stack trace, a `panic:`\n\nline — is annoying but honest: an `if $? -ne 0`\n\nbranch catches it whether or not you anticipated the specific failure mode. This is a different shape of problem. The interface contract the orchestration is trusting — exit code as the success/failure signal — stays green, while the actual work product (stdout) is empty. Anyone who wires \"exit code equals 0\" to \"mark the task done, move on\" will silently record a quota death as a completed job.\n\n`timeout`\n\nIt gets worse on macOS specifically: there's no `timeout`\n\nbinary by default, so wrapping a worker call in `timeout ...`\n\non a machine where it doesn't resolve hands back a shell \"command not found\" as exit 0 — a second, independent path to the same false-success signal.\n\nI'd already tripped over a version of this once before, with a different vendor's CLI, and filed it away as \"that tool is just weird about how it reports quota.\" Watching the identical shape — real error on stderr, empty stdout, exit 0 — show up in a completely separate CLI from a completely different vendor changed the diagnosis: this isn't a bug in one wrapper, it's how subscription-gated command-line tools tend to communicate \"you're out of quota.\" They treat it as a billing condition rather than a program error, so the message goes to stderr and the process exits cleanly rather than breaking a caller's shell pipeline with a nonzero status.\n\nOnce that's the assumption, you stop trusting exit codes from any subscription CLI by default.\n\nThe rule I apply to every worker dispatch now is boring and mechanical, which is the point:\n\n`timeout`\n\nexists on every machine it runs on, that assumption is itself a failure mode to check for — not just the worker's behavior.None of this requires knowing anything vendor-specific ahead of time. It requires treating \"exit 0\" as one weak signal among several, not the whole verdict.\n\nThis isn't specific to LLM CLIs — any pipeline-friendly tool tends to swallow certain failure classes into stderr-plus-exit-0 rather than a hard nonzero exit, because tool authors don't want a quota, rate-limit, or auth condition to break a caller's pipeline. The fix generalizes too: treat the exit code of anything you didn't write as a hint, and check the actual output before marking a step done.\n\nI've also hit the mirror-image bug elsewhere — a wrapper failing while the underlying capability still works (a false negative); this one is the opposite: dead on arrival, but the harness says yes (a false positive).\n\nThe same distrust-the-green-light instinct applies at the HTTP layer — I've separately seen a 200 OK with an error payload inside get cached as if it were good data; same root cause, different transport.\n\n*More notes at hexisteme.github.io/notes.*", "url": "https://wpnews.pro/news/exit-0-empty-stdout-the-quota-died-on-stderr", "canonical_source": "https://dev.to/hexisteme/exit-0-empty-stdout-the-quota-died-on-stderr-39h7", "published_at": "2026-08-18 09:00:05+00:00", "updated_at": "2026-08-18 09:12:38.578321+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "mlops"], "entities": ["OpenAI", "Codex CLI"], "alternates": {"html": "https://wpnews.pro/news/exit-0-empty-stdout-the-quota-died-on-stderr", "markdown": "https://wpnews.pro/news/exit-0-empty-stdout-the-quota-died-on-stderr.md", "text": "https://wpnews.pro/news/exit-0-empty-stdout-the-quota-died-on-stderr.txt", "jsonld": "https://wpnews.pro/news/exit-0-empty-stdout-the-quota-died-on-stderr.jsonld"}}