Docs/Ledger APIv0.0.1-preview

Error reference by surface

Every error a TulaDB surface can return, surface by surface — REST gateway responses, SDK error types, tulactl, tuladbd and tool exit codes, the Iceberg exporter and the operations console.

On this page
  1. What each write records
  2. REST gateway responses
  3. SDK error types
  4. tulactl exit codes
  5. tuladbd exit codes
  6. Operator tool exit codes
  7. Iceberg exporter exit codes
  8. Operations console API

Errors and rejections covers the engine’s status codes, the reject reason codes and the retry rules. This page lists what every other surface returns: the HTTP responses of the REST gateways, the error types each SDK raises, and the exit codes of the command-line tools, the daemon and the exporter. Every row was checked against the code of the current release.

Where one surface wraps another, the inner code survives. A gateway response carries the engine status number in ledgerd_error.status_code or ledgerd_response.status; an SDK error carries it as status_code or Status; tulactl prints it on stderr. Look up the number in the status code table.

What each write records

Whether a refusal leaves a durable rejected record depends on the write type as much as on the code. The rule and the per-code table are in Durable and non-durable refusals; this is the same answer by write type. Each row’s behaviour was reproduced against a running engine; codes a single node can’t produce follow the engine source, and the per-code table says which.

Write Recorded as a rejected transaction Answered and not recorded
Transfer (single or batch item) Every non-retryable verdict reached after the duplicate check: 2, 5, 6, 9, 11, 15, 23–27, 32–34, 42 (reason 0xFFFF002A) Every retryable status (4, 10, 12, 13, 18, 38, …); refusals before an identity exists: 8, 19 (reserved ledger id), 36; server-side refusals: 35, the query-port refusal (19 or 37); 17 from a halted node
Reserve (single or batch item) The same verdicts as a transfer. Its record has phase VOIDED, so the hold never existed As for a transfer
Commit / rollback, by transfer id or by key Never. A lifecycle refusal is answered from the reservation’s own record and adds nothing to it 9 for an identity never reserved (it can still be reserved afterwards); 10 after the other side won, or for a reserve that was declined; 39 after the reservation expired — the record’s EXPIRED phase is the durable fact; 21 for a hold the node can’t read yet (from code)
Explicit reject (TXN_FLAG_REJECT) Always 41, with your reject_reason_code verbatim. A transfer later sent under the same identity is answered 41 36 for a malformed reject (an engine-reserved reason code, a reason without the flag); shed with 4 or 38 like any write. A reject of an identity that already has an outcome answers that outcome (1 for a posted transfer) and records nothing
Tiered transfer / reserve (single or batch item) The same verdicts across all four legs, plus 3 (a leg on the wrong tier) and 19 when a position leg and a settlement leg name the same account. A cap breach records reason 0, not 0xFFFF002A As for a transfer, plus 19 when tiered transfers are disabled and 36 for a reject flag on a tiered frame (from code)
Tiered commit / rollback Never As for commit and rollback
Batch Each item is decided on its own and follows its write type’s row The same identity twice in one batch: the first item’s verdict counts and the later one answers 10. A refusal of the whole frame (35, 38, the query-port refusal) gives every item the frame’s status, and none is recorded
Account create No rejected-record concept Every refusal: 3, 5, 15, 22, 23, 44, capacity. A retry is decided against the current state — the same create succeeds once the ledger is registered, and an identical re-create of an existing account answers 1
Registry and admin writes — create ledger, source or currency; activate or deactivate an account, source or currency; limits, max_txn_amount, reserve expiry, source allowlists No rejected-record concept Every refusal, decided against the current state. A conflicting re-registration answers 16; a log write that fails answers 12 (reproduced with a failing log), and a retry is decided afresh

Two consequences are worth designing for:

  • A recorded decline keeps its identity. Fixing the cause — funding the account, registering the source, lifting the cap — doesn’t change the answer for the old txn_id. Submit the corrected payment under a new one.
  • A decline that couldn’t be recorded isn’t reported as that decline. If the engine can’t write the rejected record (the log write fails), it answers 18 or the log’s own status instead of the business reason, so a client never sees a final-looking decline that nothing remembers. Retry under the same identity; the request is then decided, and recorded, afresh.

REST gateway responses

All three REST gateways — Python, Java and C — map engine statuses to HTTP with the same table. The table is the contract in the SDK’s contracts/status-codes.env.

When the engine answered

Engine answer Submit (transfer, reserve, reject, commit, rollback, tiered, /v1/transactions) Read or admin call
0 OK 200, ok: true 200
1 OK_DUPLICATE 409, error: "duplicate_txn" —
41 on an explicit reject 200, ok: true — the reject is the recorded outcome —
44 ERR_ACCOUNT_CONFLICT 409, error: "account_conflict" 409, error: "account_conflict"
43 ERR_HISTORY_PURGED 410, error: "history_purged" 410, error: "history_purged"
a retryable status 503, error: "ledgerd_error", Retry-After: 1 the same
16, 17, 19, or a code the gateway doesn’t know 502, error: "ledgerd_error" the same
any other status (a decline) 200, ok: false, no error key 404, error: "ledgerd_error" for 9 ERR_NOT_FOUND; 422, error: "ledgerd_error" otherwise

Whenever the engine answered, the body has ledgerd_error with status_code (the number), status and status_name (both the name), retryable and message. A submit also carries ledgerd_response with the engine’s status number.

The 409 duplicate_txn body carries duplicate: true, status_code: 1, status: "OK_DUPLICATE" and the original transfer’s transfer_id, phase, batch_seq and batch_index. The write happened — treat it as success.

A 502 means the engine couldn’t decide: an invariant, corruption or configuration fault, or a status newer than the gateway. Alert on it; don’t retry blindly.

When the engine didn’t answer

HTTP error When What to do
503 ledgerd_connect_error The gateway couldn’t connect to the engine, or the connection dropped or timed out mid-request. No Retry-After and no ledgerd_error: there is no engine answer to carry A write’s outcome is unknown. Retry with the same txn_id

Refused by the gateway

None of these reached the engine, so nothing was recorded. Validation refusals (error: "invalid_request") carry a code from the refusal catalogue; the others are identified by their HTTP status and error alone.

HTTP error code When Gateways
400 invalid_request REQ_* The request’s shape is wrong: see the catalogue all
400 invalid_request invalid_amount Decimal amounts (feat/decimal-amounts). amount_decimal can’t be converted. reason is invalid (not a decimal string — a JSON number is refused too), too_many_decimals (more fractional digits than the currency’s scale) or overflow (too large for the engine’s integer); field names the field, scale is the currency’s scale Python, Java, C
400 invalid_request currency_not_registered Decimal amounts (feat/decimal-amounts). An amount_decimal names a currency with no registered scale, so it can’t be converted. Carries field and currency Python, Java, C
400 invalid_request REQ_FIELD_CONFLICT Decimal amounts (feat/decimal-amounts). Both amount and amount_decimal were sent; fields: ["amount", "amount_decimal"] Python, Java, C
401 unauthorized — API-key authentication is on and the key header (X-API-Key by default) is missing or wrong. Java also answers this when authentication is on but no key is configured all
403 forbidden — The caller’s role (read-only, ledger operator, cluster admin) doesn’t allow the route C only
404 not_found — No such route. Python answers FastAPI’s {"detail": "Not Found"} instead all
405 method_not_allowed — Wrong HTTP method. Python answers {"detail": "Method Not Allowed"}; C checks the method only on keyed-account routes all
413 payload_too_large — Body larger than TULADB_SDK_SERVER_MAX_BODY_BYTES (64 KiB by default) C only
422 invalid_request REQ_* A batch whose envelope is unusable: transfers missing, empty, not a list, over 1,024 items, or not JSON. A malformed item is reported at its index in a 200 instead Python, Java (C has no batch route and answers 404)
429 rate_limited — Over the per-client requests-per-minute limit all
500 internal_error — An unhandled failure inside the gateway all
503 auth_not_configured — Authentication is on but no API key is configured (Java answers 401 instead) Python, C
503 no_route — An admin call (create ledger, currencies) with no engine route configured. Python answers 503 invalid_request with REQ_INVALID here; Java answers 502 C

No gateway answers 504.

The decimal-amount codes are on the feat/decimal-amounts branches of all three gateways (Python, Java and C) and ship with the next gateway release; see Decimal amounts. They are the first gateway codes that don’t start with REQ_; they still never start with ERR_.

Inside a batch

A batch answers 200 with one result per item, in order. An item refused by the gateway carries error: "invalid_request" and its code at its index; an item the engine answered carries status, status_name, retryable and ledgerd_error. If the engine refuses the whole batch frame, every item gets the frame’s status. If the connection fails, every item gets error: "ledgerd_connect_error" and retryable: true — resubmit them under the same identities.

Where the gateways differ

Next gateway release. The three gateways’ fix/error-consistency branches implement one error contract and pass the same 53-case shared contract suite, with and without an engine. The released gateways differed on the points listed at the end.

With those branches, every refusal has the same HTTP status, error, code and field on all three gateways:

  • One body shape for every non-2xx: ok: false, error, message (plus code, field/fields where they apply). No http_status field. A 200 ok: false decline carries no top-level message; the text is in ledgerd_error.message.

  • invalid_request only with 400 (422 for a batch envelope). A missing SDK capability is 501 not_implemented; an admin call with no engine route is 503 no_route; the engine unreachable is always 503 ledgerd_connect_error, never 502.

  • Same cause, same code: a missing account id → REQ_FIELD_CONFLICT with every id field of the mode in fields; an ambiguous route → REQ_ROUTE_AMBIGUOUS with candidates as a count; an unknown or unrouted currency → REQ_FIELD_INVALID_VALUE with field: "currency" (or ledger_id); malformed JSON or a missing/wrong Content-Type → REQ_INVALID; a wrong JSON type → REQ_FIELD_INVALID_VALUE with the bare field name.

  • Limits: max_scan 1–1,000,000 (default 65,536); bodies over 64 KiB (1 MiB on batch routes) → 413 payload_too_large; a wrong method → 405 with Allow.

  • Access checks in one order: OPTIONS, 503 auth_not_configured, 429 (with Retry-After), 401, 403, 404/405, 413, 400. Python and Java refuse to start if role-grant settings are present, because only C enforces roles.

  • Integer fields take JSON integers only (breaking in the next release): "amount": "800", 1.5, 1.0 or true is REQ_FIELD_INVALID_VALUE naming the field, on every gateway and every batch item. A string amount would be ambiguous with amount_decimal ("800" means 800.00), so the message points there. Account ids stay decimal strings; a body currency takes an integer or an ISO alpha-3 code ("USD"), never a digit string; ledger create also takes a custom unit name. URL query parameters are unchanged.

The three gateways now answer every request covered by the contract identically.

Released gateways: Python answered a missing account id or an ambiguous route with REQ_INVALID, used invalid_request on 501/503, returned FastAPI {"detail": …} bodies on 404/405, prefixed query fields with query. and capped max_scan at 65,536; Java added http_status, listed REQ_ROUTE_AMBIGUOUS candidates as ids, answered an unknown currency with REQ_INVALID (a 500 on a query parameter) and auth-without-key with 401; C served some wrong-method requests and read malformed JSON as missing fields.

SDK error types

Every SDK separates three failures: an engine status (the ledger answered, and the answer was not success), a transport failure (the connection failed or dropped — a write’s outcome is unknown), and a protocol error (the reply could not be decoded — the SDK resets the connection). A declined write inside a batch is not an error in any SDK: it is a per-item status in the result.

The final and retryable sets are the same in all five SDKs and are tested against the shared contract. A code the SDK does not know is classed other, never success.

Python

Type Parent Carries Raised when
LedgerError RuntimeError message; on an engine status also status_code, status_name, msg_type and result The engine answered a non-success status for a call ("tuladb returned msg_type=130: status=N …"), a transport failure ("ledgerd connection failed: …"; the socket is reset), or a client-side failure such as a closed pool or a leader that can’t be found
LedgerProtocolError LedgerError message A reply failed framing, CRC or size checks ("invalid response frame: …"). The socket is reset
LedgerTxnStatusError LedgerError status_code, status_name, result Only from raise_for_result(), which you call yourself to turn a non-accepted result into an exception
LedgerDuplicateTxnError LedgerTxnStatusError as above raise_for_result(…, allow_duplicate=False) on status 1

Bad arguments raise ValueError or TypeError before anything is sent. The leader-aware SmartLedgerClient raises a final status at once and re-routes and retries after a transport or protocol error or a “not the leader” answer. On a transfer, reserve, tiered write or reject it returns an other status to you instead of retrying it (next SDK release; see Retrying a declined write).

Go

Type Carries Returned when
*tuladb.LedgerError Status int32, Message string The engine answered a non-success status. The only error that leaves the connection open
*tuladb.TimeoutError (matches tuladb.ErrTimeout) Op (connect, read, write), Window, Err A connect, read or write timeout. The connection is closed
tuladb.ErrNotConnected — A call on a closed or dropped client; when wrapped as “client closed during the call”, the outcome is unknown
*tuladb.ProtocolError (matches tuladb.ErrProtocol) message A reply that isn’t an engine answer, or failed framing (next SDK release; the released SDK returned a plain error, and status 0 for an unexpected message type). The connection is dropped

tuladb.StatusIsFinal, StatusIsRetryable, ErrorIsFinal and ErrorIsRetryable classify; the Error* forms return false for anything that isn’t a *LedgerError. tuladb.Retry retries retryable statuses and every non-LedgerError (treated as transport).

Java

Type Parent Carries Thrown when
LedgerStatusException LedgerProtocolException statusCode(), statusName(), msgType(), result(), isFinal(), isRetryable() The engine answered a non-success status (next SDK release; catch blocks for LedgerProtocolException still catch it)
LedgerProtocolException IOException as above; statusCode() null for a protocol failure In the released SDK, the engine-status exception. From the next SDK release it is also thrown, with no status, for a reply that isn’t an engine answer — retried like a transport failure
LedgerTimeoutException SocketTimeoutException phase (CONNECT, TLS_HANDSHAKE, WRITE, READ), timeout A timeout. The connection is closed
NotConnectedException IOException — Nothing was sent, so retrying is safe
IOException — message Framing failures ("bad wire magic", CRC mismatch, short header), TLS setup, a closed client, leader discovery failure
LedgerTxnStatusException RuntimeException statusCode(), status() Only from assertAccepted(), which you call yourself
LedgerDuplicateTxnException LedgerTxnStatusException as above assertAccepted(result, false) on status 1

Bad arguments throw IllegalArgumentException.

Rust

One enum, tuladb::Error:

Variant Carries Returned when
Error::Ledger status, message, result (the declined result, when there is one) The engine answered a non-success status
Error::Io std::io::Error; is_timeout(), is_not_connected() Transport failure
Error::Protocol message A reply that failed framing checks or isn’t an engine answer (the released SDK reported status 0 for an unexpected message type), client-side validation, or TLS setup

is_final(), is_retryable() and status() classify. RetryPolicy::retry retries Io and retryable Ledger errors, never Protocol.

C

Functions return 0 on success and -1 on failure. After a failure:

  • ledgerd_client_last_status() is the engine status, or LEDGERD_STATUS_UNKNOWN (-1) when the failure happened on the client side — connect, timeout, framing or CRC.
  • ledgerd_client_last_error() is a message for a log. It is not a contract.
  • Transport failures also set errno (ETIMEDOUT, ECONNRESET, EIO, ENOTCONN).
  • Batch items the engine never answered carry LEDGERD_STATUS_UNKNOWN as their status.

The status constants run from LEDGERD_STATUS_OK (0) to LEDGERD_STATUS_ERR_RECONFIG_NO_QUORUM (48); ledgerd_status_is_final() and ledgerd_status_is_retryable() classify.

Unknown codes

For a status number newer than the SDK, every SDK returns the name ERR_<n> (for example ERR_49), keeping the number, and classes it as other, never success. (In the released SDKs, C, Go and Rust returned ERR_UNKNOWN; the next SDK release aligns them.)

Retrying a declined write

An SDK’s automatic retry follows one rule in every language: on a transfer, reserve, tiered transfer or reject, it retries only a retryable status or a transport/protocol failure. An other status is returned to you, because on these writes most of them are durable: retrying the same txn_id would replay the same decline forever. Fix the cause and submit with a new txn_id. (The leader-aware Python, Java and C clients auto-retried other statuses in the released SDKs; the next SDK release stops it. Go’s Retry and Rust’s retry already followed the rule.)

tulactl exit codes

Next release. These exit codes are on core branch fix/exit-codes and ship with the next engine release; the released 0.0.1-preview behaviour differs where noted.

tulactl never exits with the engine status itself. A refused request prints <command> failed: server error N: NAME on stderr — the number is there for scripts. A duplicate submit exits 0 and prints ↺.

Exit Meaning
0 Done. Also help and --version
1 The engine answered with a non-success status, or the operation was refused or not confirmed
2 Usage error: unknown command, missing or invalid arguments. Checked before connecting, so it is the same answer whether or not a daemon is running
3 Cannot connect, or a transport error. For offline commands, the input file can’t be read

Some commands have codes of their own, meant for scripts and probes:

Command 0 1 3 4
health reachable, ping answered reachable, ping failed unreachable —
ready leader known, lag within --max-lag not ready: no leader, catching up, or the status call failed unreachable —
topology check OK WARN file unreadable REJECT
join-status no join in force, history OK a join in force, history not OK, or a metadata step busy journal unreadable —
digest-at-cut captured refused transport failure answered without a digest: too late, not reached, busy, multilane, unavailable
upgrade-status every node reachable a node didn’t answer — —
upgrade-finalize finalised, or nothing to finalise refused (nothing changed), aborted, or activation failed cannot connect —
autotune-plan plan printed a voter didn’t report memory, or nothing fits — —
history-floor-clear no copy still has a logical floor refused, or a floor remains — —
reconfig (offline) done refused because the node is live cluster_config.dat unreadable —

Usage errors are 2 on every command. In the released 0.0.1-preview, unreachable was 2 for health and ready, digest-at-cut used 3 for “not captured”, bad arguments exited 1 on some commands, and an unknown command was reported only after connecting.

See tulactl for the commands themselves.

tuladbd exit codes

Next release. These exit codes are on core branch fix/exit-codes and ship with the next engine release; the released 0.0.1-preview behaviour differs where noted.

Exit Meaning What to do Restarted by the systemd unit?
0 Clean exit: orderly shutdown on SIGTERM/SIGINT, --help, --version, or a successful one-shot mode such as --check-config — no
1 A start or run failure a restart may clear: data directory unusable or already open, initialisation or recovery failed, a listener couldn’t bind, or a refused --autotune-rate / --autotune-probe Read the FATAL log line and fix the cause yes
2 Engine halted (fail-stop) — and only that. The log has FATAL [daemon] engine_halted <reason>; the final metrics snapshot shows engine_halted=1 Read the reason, then restart; recovery replays from durable state. If the same reason repeats, keep the node down and investigate yes
3 --check-config --memory-plan-json couldn’t write its JSON — —
64 Usage error (EX_USAGE): unknown option, missing value, bad --assume-memory Fix the command line no
78 Configuration error (EX_CONFIG): the configuration or an environment override didn’t load or validate (including the TLS-required checks), it doesn’t fit memory with memory_fit_check = 2, node_addr can’t be bound, two ports collide, or autotune flags without autotune on Fix the configuration no
killed by SIGBUS A store into a memory-mapped data file failed — almost always a full disk; stderr says “most likely the disk is FULL” Free disk space before restarting. Normally the free-space reserve refuses writes with ERR_CAPACITY long before this yes

The shipped systemd unit restarts on failure (five starts in ten minutes) and sets RestartPreventExitStatus=64 78, so a bad command line or configuration stops instead of looping. Never add 2 to that list: a halted engine must restart and recover. --check-config exits 78 on an invalid configuration; a valid one that doesn’t fit this machine’s memory exits 1. In the released 0.0.1-preview, usage errors exited 2 (the same code as a halt) and configuration errors 1.

Halt reasons are event names in the FATAL log line, for example posted_undo_failed, pending_commit_undo_failed, rollback_release_failed, snapshot_install_partial, recovery_integrity_fail_closed, replay_capacity_refused, dedup_index_coverage_gap, apply_gap_unknown_state, stranded_halt and failed_then_kept_halt. The last two fire only with quorum_failstop_halt_on_stranded on, which is the default. See Resilience for how fail-stop fits the recovery design.

Operator tool exit codes

Every operator tool follows one scheme: 0 OK, 1 failed (including an unreadable input), 2 usage — plus the tool-specific codes below. (tuladb_chain_verify usage errors were 1 and a failed lane 2 in the released 0.0.1-preview.)

Tool 0 1 2 Other
tuladb_backup OK (verify: OK, verify-restore: OK) failed or refused: output exists without --force, no durable snapshot for online-full, restore target open by a running daemon or not empty usage —
tuladb_upgrade IN-PLACE UPGRADE SAFE, or ROLLBACK SAFE with --compat-with ROLLBACK NOT SAFE, MIGRATION REQUIRED, or no recognisable store usage, missing or unreadable data directory, config failed to load —
tuladb_verify_store verify-store: PASS verify-store: FAIL with a recovery_error:, consistency_error: or deep_verify_error: line usage, missing directory —
tuladb_audit_verify audit-verify: PASS audit-verify: FAIL, including an unreadable log usage, bad HMAC key —
tuladb_chain_verify OK a lane invalid, or no lane WAL directories usage 3: EXPECT FAIL
wal_seq_dump no gaps gaps found usage, WAL won’t open, no frames in range —
tuladb_export_txns OK config, open or write failure usage —
tuladb_prometheus_exporter --once rendered --once failed bad flag —

ROLLBACK NOT SAFE and MIGRATION REQUIRED are answers, not faults: the tool read the store and is telling you not to proceed. Don’t downgrade or wipe on a non-zero exit.

tuladb-installer exits 0 on success and 1 on every refusal or failure, printing tuladb-installer: ERROR: <reason> (2 for an internal error); a failed install rolls back what it installed (see Install TulaDB). Interrupted by Ctrl-C or SIGTERM, it rolls back and exits 130 or 143. tuladb-installer status exits 0 ready, 3 not installed, 4 unreachable, 5 not ready. The evaluation-stack installers (install.sh, install-native.sh, install-cluster.sh, install-eks.sh) exit 0 on success and 1 on every refusal, printing ✗ <reason> — for example a checksum mismatch, a failed signature check on the checksum manifest, a script piped from stdin instead of downloaded and verified, or an unsupported platform. Any other failing command exits with that command’s status.

Iceberg exporter exit codes

The exporter reserves three codes for conditions a restart can’t fix. The shipped systemd unit lists them in RestartPreventExitStatus=86 87 88, so it stops instead of looping. Kubernetes can’t exempt exit codes, so there they show as CrashLoopBackOff: scale the exporter to zero while you act.

Exit Meaning What to do
0 Clean exit (SIGTERM), or the command succeeded; status while running —
1 A command failed (run failed:, bootstrap failed: …), no tables, a stale metrics --once, an incomplete watermark, clear-durability-halt without --confirm, or an unhandled exception Read the log; restartable
2 Usage error or refusal: bad arguments, reconcile not OK, bootstrap refused:, watermark publishing disabled Fix the arguments or the configuration
3 status: not running (stale or missing pid) —
86 Durability halt. A compaction or repair commit landed but couldn’t be made durable. A DURABILITY_HALT marker sits next to the checkpoint, and run, start, once, repair, bootstrap, compact --execute and cleanup --execute refuse with 86 while it exists Check the disk and the table (reconcile), then clear-durability-halt --config … --confirm
87 Source range reclaimed. The ledger answered ERR_EXPORT_RANGE_RECLAIMED (40): the export cursor is below the WAL reclaim floor, so the gap between them is gone from the live log. stderr names the gap A decision, not a retry: reseed at the floor into a new table generation and accept the gap, or bootstrap a fresh pipeline
88 Partition-group conflict. The ledger answered ERR_EXPORT_GROUP_CONFLICT (47) with a sub-code other than 71, or the checkpoint was recorded under a different group size or epoch Fix the group configuration (index, size, pipeline id, principal) or forget the stale keys on the ledger

ERR_EXPORT_GROUP_CONFLICT with sub-code 71 means “not the leader”: the exporter retries it against the leader, and exits 1 only if that retry also fails.

Operations console API

Next console release. This mapping is on the console’s fix/error-consistency branch; the released console answered engine declines and unreachable-engine failures with 400.

The operations console’s JSON API answers errors as {"ok": false, "error": "<message>", "request_id": "…"}, with code (and sometimes field) added where one applies. Two exceptions: the role check and the sign-in route answer {"detail": "<message>"}, so read error or detail. Unhandled failures are 500 with error: "internal_error". Roles rank viewer, then operator (moves money), then admin (changes the ledger’s shape).

HTTP error / code When
200 ok: false, error: "declined", declined: true, status_code, status, status_name, retryable: false The engine declined a submit (transfer, reserve, tiered, funding, commit, rollback, reject) — the same rule as the REST gateways: a decline is an answer, not an HTTP error. A recorded explicit reject is 200 ok: true with reject_recorded: true (status 41), first time and on replay
404 / 422 ledgerd_error with status_code The engine refused an admin call (account create, limits, activation, ledger/source/currency registry): 404 for status 9, otherwise 422
400 code from the gateway catalogue (REQ_FIELD_OUT_OF_RANGE, REQ_REASON_CODE_RESERVED, …) The console refused the request on its shape
503 ledgerd_connect_error, code: "LEDGER_UNREACHABLE", retryable: true, outcome_unknown on submits The ledger couldn’t be reached during a write. The outcome is unknown; retry with the same txn_id
503 ledgerd_error, Retry-After: 1 The engine answered a retryable status (4, 10, 12, 13, 14, 18, 21, 38, 48). Nothing was written
502 ledgerd_error An engine fault status (16, 17, 19) or a code the console doesn’t know
401 unauthorized No valid session or API key. A failed sign-in answers invalid username or password
403 <role> role required; you are <role> The caller’s role is too low for the route
404 transfer not found, no such report, … No such object
409 duplicate_txn, ok: false, duplicate: true, status_code: 1, the original’s identity, original_result_code, request_id The submit was a replay of an applied write; the effect exists. On a reject, original_result_code 41 means the rejection was already recorded; any other value means the txn_id belongs to a posted transfer and no rejection was recorded
409 / 410 account_conflict / history_purged Status 44 / 43
409 RECONCILE_BUSY, IN_USE, LAKE_CONFIG, ledger_id already exists A conflict with the console’s current state
413 request body too large Body over LEDGER_DASHBOARD_MAX_BODY_BYTES (1 MiB by default)
422 code from the gateway catalogue, field, details The body failed schema validation
429 LAKE_BUSY Too many lake queries at once. The only rate limit in the console
502 engine … failed: … An engine listing call failed
503 audit log unavailable; refusing to act unrecorded LEDGER_DASHBOARD_AUDIT_REQUIRED is on and the audit sink isn’t ready. Every mutating request is refused, before authentication
503 LEDGER_UNREACHABLE The ledger is down and the answer needs it
503 dashboard API key is required in prod ENV=prod with no API key configured

Lake query errors carry LAKE_* codes: 400 for a refused statement (LAKE_SQL_SYNTAX, LAKE_STATEMENT_REFUSED, LAKE_SQL_TOO_LONG, …), 408 or 504 for LAKE_TIMEOUT, and 503 when the lake isn’t configured or available (LAKE_UNAVAILABLE, LAKE_DISABLED, LAKE_THROTTLED, …).