use crate::client::{Error as ClientError, ErrorKind};
use anyhow::Error as AnyError;
use serde_json::Value;
use std::fmt;
#[derive(Debug)]
struct CodedError {
code: &'static str,
message: String,
}
impl fmt::Display for CodedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CodedError {}
pub(super) fn coded_error(code: &'static str, message: impl Into<String>) -> AnyError {
AnyError::new(CodedError {
code,
message: message.into(),
})
}
pub(super) fn auth_error(message: impl Into<String>) -> AnyError {
coded_error("auth_failed", message)
}
pub(super) fn auth_info_error(message: impl Into<String>) -> AnyError {
coded_error("auth_uninitialized", message)
}
pub(super) fn target_error(message: impl Into<String>) -> AnyError {
coded_error("target_error", message)
}
pub(super) fn wallet_error(message: impl Into<String>) -> AnyError {
coded_error("wallet_error", message)
}
pub(super) fn with_fallback_code(error: AnyError, code: &'static str) -> AnyError {
match classified_error_code(&error) {
Some(existing) if existing != "command_failed" => error,
_ => coded_error(code, format!("{error:#}")),
}
}
pub fn error_code(error: &AnyError) -> &'static str {
classified_error_code(error).unwrap_or("command_failed")
}
pub fn error_hint(error: &AnyError) -> Option<&'static str> {
match error_code(error) {
"query_budget_exceeded" | "exec_budget_exceeded" => Some(
"SQL exceeded Circle fuel; reduce joins, window-function work, or LIMIT size, or use an index-backed access pattern.",
),
_ => None,
}
}
pub fn error_details(error: &AnyError) -> Option<Value> {
for cause in error.chain() {
if let Some(error) = cause.downcast_ref::<ClientError>()
&& let Some(details) = error.details()
{
let mut value = serde_json::to_value(details).ok()?;
if error.code() == Some("receipt_pending")
&& let Some(object) = value.as_object_mut()
&& !object.contains_key("next_command")
&& let (Some(tx_hash), Some(database)) = (
object.get("tx_hash").and_then(Value::as_str),
object.get("database").and_then(Value::as_str),
)
{
object.insert(
"next_command".to_string(),
Value::String(format!("octra-sqlite receipt {tx_hash} {database} --json")),
);
}
return Some(value);
}
}
None
}
fn classified_error_code(error: &AnyError) -> Option<&'static str> {
for cause in error.chain() {
if let Some(error) = cause.downcast_ref::<CodedError>() {
return Some(error.code);
}
if let Some(error) = cause.downcast_ref::<ClientError>() {
return Some(client_error_code(error));
}
}
None
}
fn client_error_code(error: &ClientError) -> &'static str {
if let Some(code) = error.code() {
return match code {
"rpc_rate_limited" => "rpc_rate_limited",
"rpc_non_json" => "rpc_non_json",
"target_error" => "target_error",
"storage_uninitialized" => "storage_uninitialized",
"auth_uninitialized" => "auth_uninitialized",
"result_limit_exceeded" => "result_limit_exceeded",
"query_budget_exceeded" => "query_budget_exceeded",
"exec_budget_exceeded" => "exec_budget_exceeded",
"receipt_pending" => "receipt_pending",
"receipt_target_mismatch" => "target_error",
"response_too_large" => "result_too_large",
code if code.starts_with("auth_") => "auth_failed",
code if code.starts_with("sqlite_") && error.kind() != ErrorKind::Receipt => {
"sql_rejected"
}
_ => default_client_error_code(error.kind()),
};
}
default_client_error_code(error.kind())
}
fn default_client_error_code(kind: ErrorKind) -> &'static str {
match kind {
ErrorKind::Authorization => "auth_failed",
ErrorKind::Config => "config_error",
ErrorKind::Decode | ErrorKind::Protocol => "decode_error",
ErrorKind::Receipt => "circle_write_failed",
ErrorKind::Rpc => "rpc_error",
ErrorKind::Timeout => "timeout",
ErrorKind::Transport => "rpc_unavailable",
ErrorKind::Wallet => "wallet_error",
ErrorKind::Io | ErrorKind::Other => "command_failed",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coded_cli_error_keeps_its_automation_code_through_context() {
let error = coded_error("read_only", "SQL would write").context("running SQL");
assert_eq!(error_code(&error), "read_only");
}
#[test]
fn client_source_code_takes_precedence_over_error_kind() {
let error = AnyError::new(ClientError::with_code(
ErrorKind::Rpc,
"query_budget_exceeded",
"query exceeded deterministic SQLite work limit",
));
assert_eq!(error_code(&error), "query_budget_exceeded");
assert_eq!(
error_hint(&error),
Some(
"SQL exceeded Circle fuel; reduce joins, window-function work, or LIMIT size, or use an index-backed access pattern."
)
);
}
#[test]
fn client_error_details_survive_classification() {
let error = AnyError::new(ClientError::with_code_and_details(
ErrorKind::Timeout,
"receipt_pending",
"receipt pending",
[
("tx_hash", Value::String("abc123".to_string())),
("database", Value::String("oct://devnet/octABC".to_string())),
("ou", Value::String("200000".to_string())),
],
));
let details = error_details(&error).unwrap();
assert_eq!(details["tx_hash"], "abc123");
assert_eq!(
details["next_command"],
"octra-sqlite receipt abc123 oct://devnet/octABC --json"
);
assert_eq!(details["ou"], "200000");
}
#[test]
fn client_error_kind_supplies_the_stable_fallback() {
let error = AnyError::new(ClientError::with_kind(
ErrorKind::Wallet,
"private key unavailable",
));
assert_eq!(error_code(&error), "wallet_error");
}
#[test]
fn submitted_sqlite_failure_remains_a_circle_write_failure() {
let error = AnyError::new(ClientError::with_code(
ErrorKind::Receipt,
"sqlite_exec_failed",
"SQL execution failed",
));
assert_eq!(error_code(&error), "circle_write_failed");
}
#[test]
fn command_fallback_codes_only_untyped_errors() {
let untyped =
with_fallback_code(anyhow::anyhow!("wallet path is required"), "wallet_error");
assert_eq!(error_code(&untyped), "wallet_error");
assert_eq!(untyped.to_string(), "wallet path is required");
let typed = AnyError::new(ClientError::with_kind(
ErrorKind::Config,
"config could not be read",
));
let typed = with_fallback_code(typed, "wallet_error");
assert_eq!(error_code(&typed), "config_error");
}
#[test]
fn stable_error_vocabulary_has_typed_classifications() {
for code in [
"sql_too_large",
"transactions_not_supported",
"read_only",
"bootstrap_unverified",
"bootstrap_already_done",
"auth_uninitialized",
"receipt_pending",
"target_error",
"wallet_error",
] {
assert_eq!(error_code(&coded_error(code, code)), code);
}
for (source, expected) in [
("result_limit_exceeded", "result_limit_exceeded"),
("query_budget_exceeded", "query_budget_exceeded"),
("exec_budget_exceeded", "exec_budget_exceeded"),
("receipt_pending", "receipt_pending"),
("receipt_target_mismatch", "target_error"),
("response_too_large", "result_too_large"),
("sqlite_prepare_failed", "sql_rejected"),
("auth_denied", "auth_failed"),
("storage_uninitialized", "storage_uninitialized"),
("auth_uninitialized", "auth_uninitialized"),
("target_error", "target_error"),
("rpc_rate_limited", "rpc_rate_limited"),
("rpc_non_json", "rpc_non_json"),
] {
let error = AnyError::new(ClientError::with_code(ErrorKind::Rpc, source, source));
assert_eq!(error_code(&error), expected, "source code {source}");
}
for (kind, expected) in [
(ErrorKind::Authorization, "auth_failed"),
(ErrorKind::Receipt, "circle_write_failed"),
(ErrorKind::Wallet, "wallet_error"),
(ErrorKind::Timeout, "timeout"),
(ErrorKind::Decode, "decode_error"),
(ErrorKind::Transport, "rpc_unavailable"),
(ErrorKind::Rpc, "rpc_error"),
(ErrorKind::Config, "config_error"),
(ErrorKind::Other, "command_failed"),
] {
let error = AnyError::new(ClientError::with_kind(kind, expected));
assert_eq!(error_code(&error), expected, "error kind {kind:?}");
}
}
}