Error Handling

The Tenzro Platform Rust SDK represents all failure cases with a single flat enum, TenzroError. It is derived with thiserror 2.0 and integrates cleanly with the ? operator for ergonomic error propagation. There are no sub-structs such as ApiError or ValidationError — every variant is defined directly on the enum.

Importing

use tenzro_platform::{TenzroError, Result};
// or, using the full module path:
use tenzro_platform::error::{TenzroError, Result};

The TenzroError Enum

All SDK operations return Result<T>, which is a type alias for std::result::Result<T, TenzroError>. The full definition is:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum TenzroError {
    /// HTTP transport error — automatically converted from reqwest::Error via From.
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),

    /// An error response returned by the Tenzro API.
    /// Carries the HTTP status code, a machine-readable error code, and a human-readable message.
    #[error("API error ({status}): {code} - {message}")]
    Api {
        status: u16,
        code: String,
        message: String,
    },

    /// An invalid or missing SDK configuration value.
    #[error("Configuration error: {0}")]
    Config(String),

    /// JSON (de)serialisation error — automatically converted from serde_json::Error via From.
    #[error("Serialization error: {0}")]
    Serialization(#[from] serde_json::Error),

    /// A caller-supplied argument was rejected before a network request was made.
    #[error("Invalid argument: {0}")]
    InvalidArgument(String),

    /// The requested resource does not exist (HTTP 404).
    #[error("Not found: {0}")]
    NotFound(String),

    /// The request lacked valid credentials or the credentials have expired (HTTP 401).
    #[error("Unauthorized: {0}")]
    Unauthorized(String),

    /// Too many requests have been sent in a given time window (HTTP 429).
    #[error("Rate limit exceeded: {0}")]
    RateLimited(String),

    /// The request did not complete within the allowed time.
    #[error("Timeout: {0}")]
    Timeout(String),
}

/// Convenience alias — equivalent to std::result::Result<T, TenzroError>.
pub type Result<T> = std::result::Result<T, TenzroError>;

Variant Reference

VariantInner dataTypical cause
Http(reqwest::Error)Transport-level error from reqwestConnection refused, DNS failure, TLS error
Api { status, code, message }u16, String, String4xx / 5xx response from the Tenzro API not matched by a more specific variant
Config(String)Description of the bad configurationMissing API key, invalid base URL
Serialization(serde_json::Error)serde_json errorUnexpected response shape, malformed JSON in the API response
InvalidArgument(String)Description of the invalid argumentEmpty string where one is required, out-of-range value
NotFound(String)Description of the missing resourceWallet ID, contract ID, or asset that does not exist (HTTP 404)
Unauthorized(String)Reason for the authentication failureInvalid or expired API key (HTTP 401)
RateLimited(String)Message describing the limit that was hitToo many requests in a short window (HTTP 429)
Timeout(String)Description of which operation timed outRequest exceeded the configured timeout

Automatic From Conversions

Two variants carry #[from] attributes, so the Rust compiler generates From implementations for them automatically:

  • reqwest::Error converts to TenzroError::Http
  • serde_json::Error converts to TenzroError::Serialization

This means the ? operator works without any manual conversion in any async function that returns Result<T>.

How from_response Works

The SDK uses an internal async method TenzroError::from_response to convert a non-success HTTP response into the most appropriate variant. It reads the response body, attempts to parse a JSON object with code/error and message/detail fields, and then dispatches on the HTTP status:

  • 401Unauthorized(message)
  • 404NotFound(message)
  • 429RateLimited(message)
  • Any other non-success status → Api { status, code, message }

If the body cannot be parsed as JSON, an Api variant is returned with code: "UNKNOWN" and the raw body as the message.

Basic Error Propagation

Use the ? operator to propagate errors up the call stack without manual match expressions:

use tenzro_platform::{TenzroPlatform, Result};

async fn fetch_balance(platform: &TenzroPlatform, wallet_id: &str) -> Result<u64> {
    // ? converts any TenzroError variant and returns early on failure
    let balance = platform
        .wallet()
        .balance(wallet_id)
        .await?;

    Ok(balance.amount)
}

Pattern Matching on Variants

Match on TenzroError to handle specific failure cases differently:

use tenzro_platform::TenzroError;
use tokio::time::{sleep, Duration};

match platform.wallet().create(request).await {
    Ok(wallet) => {
        println!("Created wallet: {}", wallet.id);
    }

    Err(TenzroError::Unauthorized(msg)) => {
        eprintln!("Authentication failed: {}", msg);
        // Refresh credentials and retry, or surface to the user.
    }

    Err(TenzroError::RateLimited(msg)) => {
        eprintln!("Rate limited: {}", msg);
        sleep(Duration::from_secs(5)).await;
    }

    Err(TenzroError::Api { status, code, message }) => {
        eprintln!("API responded with HTTP {}: [{}] {}", status, code, message);
    }

    Err(TenzroError::NotFound(msg)) => {
        eprintln!("Resource not found: {}", msg);
    }

    Err(TenzroError::InvalidArgument(msg)) => {
        eprintln!("Bad argument: {}", msg);
    }

    Err(TenzroError::Timeout(msg)) => {
        eprintln!("Request timed out: {}", msg);
    }

    Err(TenzroError::Http(e)) => {
        eprintln!("Transport error: {}", e);
    }

    Err(TenzroError::Serialization(e)) => {
        eprintln!("Could not parse response: {}", e);
    }

    Err(TenzroError::Config(msg)) => {
        eprintln!("Configuration problem: {}", msg);
    }
}

Inspecting the Api Variant

The Api variant is a struct variant with three named fields: status: u16, code: String, and message: String. Note that Unauthorized, NotFound, and RateLimited are surfaced as their own variants rather than falling through to Api, so in practice Api carries generic 4xx and 5xx responses.

if let Err(TenzroError::Api { status, code, message }) = result {
    match status {
        400 => eprintln!("Bad request [{}]: {}", code, message),
        403 => eprintln!("Forbidden [{}]: {}", code, message),
        500..=599 => eprintln!("Server error {} [{}]: {}", status, code, message),
        other => eprintln!("HTTP {} [{}]: {}", other, code, message),
    }
}

Result Type Alias

// Defined in the SDK as:
pub type Result<T> = std::result::Result<T, TenzroError>;

// Use it as the return type of your own functions to keep signatures concise:
use tenzro_platform::Result;

async fn list_wallets(platform: &TenzroPlatform) -> Result<Vec<Wallet>> {
    platform.wallet().list().await
}

The is_retryable() Method

TenzroError provides an is_retryable() method that returns true for transient errors that are safe to retry:

impl TenzroError {
    /// Check if this error is transient and safe to retry
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::Http(_) | Self::Timeout(_) | Self::RateLimited(_) => true,
            Self::Api { status, .. } => matches!(*status, 502 | 503 | 504),
            _ => false,
        }
    }
}
VariantRetryable?Reason
HttpYesTransport-level failure (connection reset, DNS)
TimeoutYesRequest exceeded deadline
RateLimitedYesHTTP 429 - will succeed after backoff
Api { 502/503/504 }YesServer gateway/availability errors
Api { 4xx }NoClient errors require fixing the request
UnauthorizedNoInvalid credentials
NotFoundNoResource does not exist
Config / InvalidArgument / SerializationNoProgramming errors

Built-in Retry Middleware

The SDK's internal HTTP client includes automatic retry with exponential backoff for all retryable errors. The default configuration retries up to 3 times with 500ms initial backoff, 2.0x multiplier, and a 10-second maximum backoff cap.

This means most transient failures (network blips, 502/503/504, rate limits) are handled automatically without any caller-side retry logic. The retry middleware uses the same is_retryable() method documented above to decide which errors to retry.

// Default retry behavior (built into every SDK call):
//   max_retries:       3
//   initial_backoff:   500ms
//   backoff_multiplier: 2.0x  (500ms → 1s → 2s → ...)
//   max_backoff:       10,000ms
//
// Retryable errors: Http, Timeout, RateLimited, Api{502|503|504}
// All other errors fail immediately.

// No extra code needed — retries happen transparently:
let parties = platform.ledger().list_parties().await?;

Custom Retry Logic

If you need different retry behavior (more attempts, different backoff, or retrying additional error types), you can implement your own retry loop using is_retryable():

use tenzro_platform::{TenzroError, Result};
use tokio::time::{sleep, Duration};

async fn with_custom_retry<T, F, Fut>(
    mut f: F,
    max_retries: u32,
) -> Result<T>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T>>,
{
    let mut attempts = 0;

    loop {
        match f().await {
            Ok(result) => return Ok(result),
            Err(e) if e.is_retryable() && attempts < max_retries => {
                attempts += 1;
                let delay = Duration::from_millis(500 * 2_u64.pow(attempts - 1));
                tracing::warn!(
                    "Retryable error (attempt {}/{}), retrying in {:?}: {}",
                    attempts, max_retries, delay, e
                );
                sleep(delay).await;
            }
            Err(e) => return Err(e),
        }
    }
}

Wrapping TenzroError in Your Own Error Type

You can embed TenzroError inside your application's own error enum using thiserror's #[from] attribute. The ? operator then performs the conversion automatically wherever your function returns your own error type.

use thiserror::Error;
use tenzro_platform::TenzroError;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Tenzro SDK error: {0}")]
    Sdk(#[from] TenzroError),

    #[error("Configuration error: {0}")]
    Config(String),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
}

async fn provision_wallet(
    platform: &TenzroPlatform,
    db: &sqlx::PgPool,
) -> Result<String, AppError> {
    // ? converts TenzroError -> AppError::Sdk automatically
    let wallet = platform.wallet().create(request).await?;

    // ? converts sqlx::Error -> AppError::Database automatically
    sqlx::query("INSERT INTO wallets (id) VALUES ($1)")
        .bind(&wallet.id)
        .execute(db)
        .await?;

    Ok(wallet.id)
}

Using anyhow for Application Code

Because TenzroError implements std::error::Error, it works seamlessly with anyhow and anyhow::Context for top-level application code where you do not need to match on specific variants:

use anyhow::{Context, Result};
use tenzro_platform::TenzroPlatform;

async fn run() -> Result<()> {
    let platform = TenzroPlatform::from_env()
        .context("Failed to initialise Tenzro platform client")?;

    let wallets = platform
        .wallet()
        .list()
        .await
        .context("Failed to list wallets")?;

    for w in wallets {
        println!("{}: {}", w.name, w.id);
    }

    Ok(())
}

Structured Logging with tracing

The named fields on TenzroError::Api map naturally to structured log fields:

use tracing::{error, warn, info};
use tenzro_platform::TenzroError;

async fn process_transfer(platform: &TenzroPlatform, request: TransferRequest) {
    match platform.token().transfer(request).await {
        Ok(transfer) => {
            info!(tx_hash = %transfer.tx_hash, "Transfer successful");
        }

        Err(TenzroError::RateLimited(msg)) => {
            warn!(message = %msg, "Rate limited during transfer");
        }

        Err(TenzroError::Api { status, code, message }) => {
            error!(
                http_status = status,
                error_code = %code,
                message = %message,
                "API error during transfer"
            );
        }

        Err(e) => {
            error!(error = %e, "Unexpected error during transfer");
        }
    }
}