talea

How to use the Rust SDK

Drive a talea server from Rust with TaleaClient — the typed client that implements the same LedgerApi trait as the server’s in-process service. Result: posting, reading, and streaming with retries and idempotency handled for you.

Prerequisites

Add the dependency (path or git while unpublished):

[dependencies]
talea-client = { path = "../talea/talea-client" }
talea-core   = { path = "../talea/talea-core" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
futures = "0.3"

Steps

  1. Build a client:

    use std::time::Duration;
    use talea_client::{RetryPolicy, TaleaClient};
    
    let client = TaleaClient::builder("http://127.0.0.1:8080")
        .bearer_token(std::env::var("TALEA_TOKEN")?)
        .timeout(Duration::from_secs(30))      // per-request; not applied to subscriptions
        .retry(RetryPolicy::default())          // 3 attempts, exponential 200ms..5s
        .build()?;                              // validates the URL; no network I/O yet
    

    The default retry policy retries 503, 429, 408, and transport errors, honoring Retry-After. That is safe for every operation because posts carry idempotency keys and registry writes are idempotent. RetryPolicy::none() disables it.

  2. Register an asset and open accounts (both idempotent — run them at startup unconditionally):

    use talea_core::api::*;
    
    client.register_asset(AssetDraft {
        id: "USD".into(), class: "fiat".into(),
        network: None, native_id: None,
        precision: 2, name: "US Dollar".into(),
    }).await?;
    
    client.open_account(AccountDraft {
        book: "demo".into(), path: "cash".into(), asset: "USD".into(),
        kind: "asset".into(),
        normal_side: Some(talea_core::types::Direction::Debit),
        min_balance: Some(0),   // never overdraw
    }).await?;
    
  3. Post a transaction. The idempotency key is yours to choose — derive it from the business event (an order id, a deposit txid), never generate it per attempt:

    let posted = client.post(TransactionDraft {
        book: "demo".into(),
        idempotency_key: format!("deposit-{order_id}"),
        postings: vec![
            PostingDraft {
                account: "cash".into(),
                amount: WireAmount { minor: 100_000, asset: "USD".into() },
                direction: talea_core::types::Direction::Debit,
            },
            PostingDraft {
                account: "equity".into(),
                amount: WireAmount { minor: 100_000, asset: "USD".into() },
                direction: talea_core::types::Direction::Credit,
            },
        ],
        external_refs: vec![],
        metadata: serde_json::json!({"channel": "wire"}),
        occurred_at: None,      // business time; None = server now
    }).await?;
    
    println!("seq {} deduplicated {}", posted.seq, posted.deduplicated);
    

    Re-running this with the same key returns the original commit with deduplicated: true — call it again on any doubt.

  4. Post a batch. Use post_batch when you have multiple independent transactions to commit — bulk ingestion, backfills, processing a queue of events — and want to minimize round-trips. Do not use it on interactive paths where each draft depends on the result of the previous one; client-side batching adds client-side latency that the caller has to absorb.

    let results: Vec<ApiResult<Posted>> = client.post_batch(vec![
        TransactionDraft { book: "demo".into(), idempotency_key: "batch-a".into(), /* ... */ },
        TransactionDraft { book: "fees".into(), idempotency_key: "batch-b".into(), /* ... */ },
    ]).await;
    
    for (i, result) in results.iter().enumerate() {
        match result {
            Ok(posted) => println!("slot {i}: seq {} deduplicated {}", posted.seq, posted.deduplicated),
            Err(e) => eprintln!("slot {i}: {:?}", e),
        }
    }
    
    • results[i] always corresponds to drafts[i]. A failure in one slot does not affect any other slot.
    • Drafts may span different books. Each draft’s idempotency key deduplicates independently, as it would through post.
    • An empty input returns an empty Vec immediately without any network call.
    • Whole-request failure — a 401, 415, 400, or transport error that prevents the server from processing the batch at all is replicated into every slot as the same Err. You can detect this because all slots carry an identical error. Retrying the whole batch is safe: any draft that already committed returns deduplicated: true rather than double-posting.
  5. Read:

    let bal = client.balance("demo", "cash", None).await?;       // live
    let then = client.balance("demo", "cash", Some(ts)).await?;  // as-of commit time
    
    // paginate history: `after_seq` is the previous page's `next`
    let mut cursor = None;
    loop {
        let page = client.account_history("demo", "cash", Page { after_seq: cursor, limit: 100 }).await?;
        for p in &page.items { /* ... */ }
        match page.next { Some(n) => cursor = Some(n), None => break }
    }
    
  6. Stream events. subscribe resumes automatically across disconnects (cursor via Last-Event-ID), and its retry budget resets on every received event, so long-lived streams survive unlimited transient drops:

    use futures::StreamExt;
    
    let mut stream = client.subscribe("demo", 1).await?;   // from = first seq, inclusive
    while let Some(item) = stream.next().await {
        match item {
            Ok(env) => println!("seq {} kind {}", env.seq, env.kind),
            Err(e) => { eprintln!("stream ended: {e:?}"); break }   // persistent failure only
        }
    }
    

    Delivery is at-least-once: dedupe on env.seq if you checkpoint.

  7. Match errors where it matters. Everything is one enum:

    use talea_core::api::ApiError;
    
    match client.post(draft).await {
        Ok(posted) => { /* committed (or deduplicated) */ }
        Err(ApiError::ConstraintViolation { account, min_balance, would_be }) => {
            // business rejection: balance would breach the floor — do NOT retry
        }
        Err(ApiError::Overloaded) => { /* already retried by policy; back off at your layer */ }
        Err(ApiError::Transport { message }) => { /* network/budget exhausted: safe to re-post, same key */ }
        Err(e) => { /* validation errors: unbalanced, unknown_account, ... — fix the draft */ }
    }
    

Verification

The trait symmetry gives you a free integration seam: write your code against talea_core::api::LedgerApi and it runs identically against TaleaClient (remote) or LedgerService (in-process, e.g. SQLite in tests). With a live server:

cargo run --example your_integration   # or: talea balance --book demo --path cash

A balance matching what you posted is the round-trip proof.

Troubleshooting