Skip to content

Exit codes

castiron fails loudly. Every failure carries a documented, stable exit code, so a script can tell "your input was wrong" from "castiron has a bug" without parsing text.

Code Meaning Typical causes
0 Success Files written, --dry-run completed, or castiron check found no drift
1 An actionable failure — something you can fix Unreachable source, a --from URL castiron cannot parse, bad key, unreadable OpenAPI document, a schema with no visible tables, a bad [tool.castiron] table, a target that exists under --no-overwrite, an unwritable output path
2 Usage error Unknown option, unknown --emit value, --filename with two emitters, a --config file that does not exist, a --from that is missing or is neither a URL nor an existing file, a --from URL carrying credentials in its userinfo, a --key containing a control character
3 Drift — the committed generated code is not what the schema produces castiron check found a file that differs, or a file it would write that is not there. gen never returns it
70 Internal error — a castiron bug An unexpected exception. 70 is EX_SOFTWARE from BSD sysexits

What each one means for you

0 — the run did what you asked. Under --dry-run this means the pipeline succeeded and nothing was written, which is exactly what you want in a "would this work?" CI step.

1 — fix your input, your key, or your config. The message names the thing that failed, and where a next step exists castiron adds a Hint: line:

Error: https://abcdefgh.supabase.co/rest/v1/ returned HTTP 401: check the API key and the role's privileges (PostgREST hides objects the API role cannot access).
Hint: the key came from CASTIRON_KEY. Check it is current and that its role can read the schema.

A --from URL that is not a URL Python can parse is exit 1 too, not a castiron bug — you typo'd it, and the message says so and quotes it back:

Error: Could not parse http://[::1 as a URL: check the scheme, the host, and the [brackets] around an IPv6 address.
Hint: --from takes a Supabase project URL (https://<ref>.supabase.co), a PostgREST API root, or a path to an OpenAPI JSON document.

castiron treats an empty schema as a failure rather than writing an empty models file — the common anon-key-plus-RLS case — because a zero-byte schema.py committed to your repo is a much more expensive mistake than a red build:

Error: The OpenAPI document exposes no tables or views for schema 'public'; check the API key's role privileges (PostgREST hides objects the role cannot access) and the Accept-Profile schema.
Hint: try --schema <name> if your tables do not live in 'public', or a key whose role can see them. castiron read empty.json. castiron refuses to write an empty models file.

2 — you called the command wrong. These come from click and always print the usage line, so the fix is usually visible in the output:

Usage: castiron gen [OPTIONS]
Try 'castiron gen --help' for help.

Error: No schema source. Pass --from <url|path>, set CASTIRON_FROM, or add `from = "..."` under [tool.castiron] in pyproject.toml.

Two of the exit-2 paths are not typos at all. Both inputs are well-formed; castiron is declining to use a credential you passed it, and neither message repeats the value it rejected — echoing it back is precisely the leak the refusal exists to prevent.

A --from URL with credentials in its userinfo (https://user:password@host/, or a bare https://token@host/) is refused before any request:

Error: The --from URL carries credentials in its userinfo (the `user:password@` before the host). castiron will not use it: the HTTP client rejects such a URL before it opens a socket, and the error it raises quotes the host back -- which would print your password. Drop the `user:password@` and pass the key with --key or CASTIRON_KEY.

Nothing is lost: Python's HTTP client never applies userinfo as Basic auth, so that URL could not have fetched a document. Pass the credential as --key or CASTIRON_KEY. Only http and https values are refused — a database DSN's password is left alone, and masked wherever it is printed. See the --from table.

A --key with a control character inside it — a key pasted across two lines, most often — is refused for the same reason: the failure it would otherwise cause quotes the value back at you.

Error: The API key contains a control character (a newline, carriage return or tab). A key pasted across two lines, or read from a file with Windows (CRLF) line endings, is the usual cause -- re-save it with LF endings or strip it (`tr -d "\r" < key.txt`). castiron will not send it: an HTTP header cannot carry that value, and the error the HTTP client raises quotes the value back with repr(), which would print your key.

Control characters around the key are trimmed rather than refused, so a key file with CRLF endings needs no fixing at all. Only an interior one — where the value is not the key you think it is — stops the run.

3 — your committed generated code has drifted. Only castiron check returns it; gen never does, so a script can tell "regeneration failed" from "regeneration is needed" without parsing text. The fix is always the same command:

castiron: drift detected in 1 of 1 generated file(s).

  file:     src/myapp/schema.py
  size:     36120 chars on disk -> 36244 chars from the schema
  sha256:   1f2a9c0b4d5e6f70 on disk -> 9c04ab12cd34ef56 from the schema
  lines:    +4 / -1
  showing 1 of 1 hunk(s):
    --- on disk
    +++ produced from the schema
    @@ -812,6 +812,7 @@
    ...

castiron: run `castiron gen` to regenerate.

A missing file is exit 3 too, not exit 1, and the rule behind that is worth stating because it decides every other case: every outcome in which the comparison ran and the answer is "not identical" is 3; 1 is for "castiron could not perform the comparison at all." A file that is not there is an answer — the committed state does not match the schema — and your next action is the same as for any other drift. A permission error is not an answer.

The accepted cost is that a typo'd --output also exits 3, so the message names the resolved path and the flag it came from:

  src/myapp/schema.py does not exist.
  castiron would write it here (resolved from --output src/myapp).
  Run `castiron gen` to create it, or check that --output points where your generated
  files actually live.

70 — please report it. castiron distinguishes its own bugs from your input so a bug report is actionable. The traceback is hidden by default and the message says exactly how to get it:

castiron: internal error (RuntimeError: kaboom). This is a bug in castiron, please report it at https://github.com/kmbhm1/castiron/issues -- rerun with --debug for the traceback.

--debug adds the traceback without changing the exit code

--debug prints the full traceback — chained exceptions included — after the message above, and the process still exits 70. castiron prints that traceback itself, so it passes through the same redaction as every other string castiron prints: an API key, a URL's user:password@, or a ?service_role_key= value in the traceback is masked before you see it. Paste the whole thing into a bug report.

Scripting against them

#!/usr/bin/env bash
set -uo pipefail

castiron check --from "$SOURCE" --emit pydantic --output src/myapp/models
case $? in
  0)  echo "generated code is up to date" ;;
  1)  echo "castiron could not read the schema or the committed files"; exit 1 ;;
  2)  echo "bad castiron invocation — check the flags"; exit 1 ;;
  3)  echo "drift: run castiron gen and commit the result"; exit 1 ;;
  70) echo "castiron bug — rerun with --debug and open an issue"; exit 1 ;;
esac

Note the deliberate absence of set -e on the castiron line: with set -e the script exits before it can read $?.

Why this is a stated contract

castiron's predecessor, supabase-pydantic, logged connection failures and returned — exiting 0 on failure, so a broken CI job looked green and stale models shipped. castiron will not do that. Every path out of gen and check ends at one of the codes above.