Errors

The one Error type, its nine variants, stable rejection codes, and what to retry.

On this page
  1. What to call
  2. Nine variants
  3. Stable rejection codes
  4. What to retry
  5. In an HTTP handler
  6. Limits

What to call

Every operation returns doubleentry::Result with one Error. Error::code and Error::rejection reach the Rejection inside it; is_transient and may_have_applied say what to retry. Payload types are in doubleentry::error.

Nine variants

VariantMeans
Rejected(Rejection)the ledger refused the input — any write
BatchRejected { index, rejection }draft index of record_batch was refused; nothing of the batch was written
Value(ValueError)a value constructor (LedgerId::new, Label::new, …) refused its input
Amount(AmountError)money parsing or arithmetic failed
Query(QueryError)a read asked an impossible question: an unknown account, entry or period, a position past the head
Transparency(…)a checkpoint, cosignature, policy, proof or bundle was refused
Integrity(IntegrityError)the ledger's stored data fails verification
Backend(BackendError)the database failed, or the OS had no randomness for a memo salt
Config(ConfigError)the store holds another ledger, scale or schema

Value and Amount never come from a ledger operation: draft builders cannot fail, so a malformed path or amount inside a draft is Rejected with a code.

use doubleentry::prelude::*;
use doubleentry::error::{QueryError, ValueError};

assert!(matches!(LedgerId::new("has space").map_err(Error::from), Err(Error::Value(ValueError::InvalidCharacter { .. }))));
assert!(matches!(Amount::<2>::parse("1.001").map_err(Error::from), Err(Error::Amount(_))));

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;

let bad_path = books.record(
    Entry::new("sale-1", date!("2026-03-15"))
        .debit("Assets::Cash", amount!("1.00"), EUR)
        .credit("Income:Sales", amount!("1.00"), EUR),
);
assert_eq!(bad_path.err().and_then(|e| e.code()), Some(103)); // InvalidPath

let read = books.balance("Assets:Bank", EUR, &BalanceQuery::all());
assert!(matches!(read, Err(Error::Query(QueryError::UnknownAccount { .. }))));

Stable rejection codes

Every Rejection has a numeric code(). Codes are append-only: a code keeps its meaning, and a retired code is never reused. The hundreds name the stage that refused the input; the stages and the checks within them run in a fixed order, so an input that breaks several rules always gets the same code, from Journal, Ledger and every store.

CodesStageChecks
100–123, 140–145structuralthe input alone; the shape of a hold or resolution
200idempotencya key already used for other content
300–311contextperiods, accounts, currencies, policy, watermark on the booking date
400–431, 440–450referencesreversal targets, cleared postings, master data, the hold a resolution names
500–501limitsoverflow and balance limits

The full list is on Rejection. Display and {:?} both print one line with the code first — rejected: [106] EUR Settled does not balance: debits 10.00, credits 9.00 — so a main returning doubleentry::Result<()> reports the code; {:#?} shows the structure.

What to retry

is_transient() (a retry may succeed) and may_have_applied() (the write may have committed although it failed) are only ever true for Backend.

ErrorWhat the caller does
Rejected, BatchRejectedfix the input (or drop draft index); never retry unchanged
Value, Amount, Queryfix the call
Transparencythe artefact is forged, stale or foreign; never retry it unchanged
Integrity(e)stop writing; investigate e.position(); restore and audit
Backend, transient or outcome unknownretry with the same draft: it applies once or replays
Backend, permanentan operator problem: configuration, permissions
Configfix the URL, schema name, ledger id or P; the data is fine
use doubleentry::prelude::*;
use doubleentry::error::BackendError;

let lost = Error::from(BackendError::transient(std::io::Error::other("connection reset")).outcome_unknown());
assert!(lost.is_transient() && lost.may_have_applied());
assert_eq!(lost.code(), None);

In an HTTP handler

Map the variants once, at the edge. Key on the code, not the Display text.

use doubleentry::prelude::*;

fn respond(outcome: Result<Recorded>) -> (u16, Option<u16>) {
    match outcome {
        Ok(r) if r.replayed => (200, None), // a retry: the original receipt
        Ok(_) => (201, None),
        Err(Error::Rejected(r @ Rejection::KeyConflict { .. })) => (409, Some(r.code())),
        Err(Error::Rejected(r)) => (422, Some(r.code())),
        Err(e) if e.is_transient() || e.may_have_applied() => (503, None),
        Err(_) => (500, None), // Integrity, Backend, Config: page an operator
    }
}

let mut books = Journal::new(LedgerId::new("example.com/shop")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
let order = |key: &str, on, eur| {
    Entry::new(key, on)
        .debit("Assets:Cash", eur, EUR)
        .credit("Income:Sales", eur, EUR)
};

assert_eq!(respond(books.record(order("order-17", date!("2026-03-15"), amount!("20.00")))).0, 201);
assert_eq!(respond(books.record(order("order-17", date!("2026-03-15"), amount!("20.00")))).0, 200);
assert_eq!(respond(books.record(order("order-17", date!("2026-03-15"), amount!("21.00")))), (409, Some(200)));
assert_eq!(respond(books.record(order("order-18", date!("2026-04-02"), amount!("5.00")))), (422, Some(300)));

Limits

  • Display texts are for people and may be reworded; the codes and variants are the contract.
  • is_transient() is the store's judgement of one failure, not a promise. Bound the retries.
  • Rejection is #[non_exhaustive]: a match needs a catch-all arm.
  • BackendError is opaque: the driver error is reachable through source(), but no sqlx type is part of the API.