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
| Variant | Inner data | Typical cause |
|---|---|---|
Http(reqwest::Error) | Transport-level error from reqwest | Connection refused, DNS failure, TLS error |
Api { status, code, message } | u16, String, String | 4xx / 5xx response from the Tenzro API not matched by a more specific variant |
Config(String) | Description of the bad configuration | Missing API key, invalid base URL |
Serialization(serde_json::Error) | serde_json error | Unexpected response shape, malformed JSON in the API response |
InvalidArgument(String) | Description of the invalid argument | Empty string where one is required, out-of-range value |
NotFound(String) | Description of the missing resource | Wallet ID, contract ID, or asset that does not exist (HTTP 404) |
Unauthorized(String) | Reason for the authentication failure | Invalid or expired API key (HTTP 401) |
RateLimited(String) | Message describing the limit that was hit | Too many requests in a short window (HTTP 429) |
Timeout(String) | Description of which operation timed out | Request exceeded the configured timeout |
Automatic From Conversions
Two variants carry #[from] attributes, so the Rust compiler generates From implementations for them automatically:
reqwest::Errorconverts toTenzroError::Httpserde_json::Errorconverts toTenzroError::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:
- 401 →
Unauthorized(message) - 404 →
NotFound(message) - 429 →
RateLimited(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,
}
}
}| Variant | Retryable? | Reason |
|---|---|---|
Http | Yes | Transport-level failure (connection reset, DNS) |
Timeout | Yes | Request exceeded deadline |
RateLimited | Yes | HTTP 429 - will succeed after backoff |
Api { 502/503/504 } | Yes | Server gateway/availability errors |
Api { 4xx } | No | Client errors require fixing the request |
Unauthorized | No | Invalid credentials |
NotFound | No | Resource does not exist |
Config / InvalidArgument / Serialization | No | Programming 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");
}
}
}