Error Handling

HORUS provides a unified error handling system built on Rust's Result type, with rich error contexts and helpful diagnostics.

Quick Start

use horus::prelude::*;

fn my_function() -> Result<()> {
    // Your code here
    Ok(())
}

The prelude exports these error types:

  • Error - The main error enum (short alias for HorusError)
  • Result<T> - Alias for std::result::Result<T, Error> (short alias for HorusResult<T>)

The long names HorusError and HorusResult<T> still work for backward compatibility, but new code should prefer the short aliases.

Core Error Types

Error

The main error type for all HORUS operations (also available as HorusError for backward compatibility):

Error Variants

Each variant wraps a structured sub-error enum with specific fields for pattern matching:

VariantSub-error typeDomain
Io(std::io::Error)File system and I/O errors
Config(ConfigError)ConfigErrorConfiguration parsing/validation
Communication(CommunicationError)CommunicationErrorIPC, topics, mDNS, network
Node(NodeError)NodeErrorNode lifecycle (init, tick, shutdown)
Memory(MemoryError)MemoryErrorSHM, mmap, tensor pools
Serialization(SerializationError)SerializationErrorJSON, YAML, TOML, binary
NotFound(NotFoundError)NotFoundErrorMissing frames, topics, nodes
Resource(ResourceError)ResourceErrorAlready exists, permission denied, unsupported
InvalidInput(ValidationError)ValidationErrorOut-of-range, invalid format, constraints
Parse(ParseError)ParseErrorInteger, float, boolean parsing
InvalidDescriptor(String)Cross-process tensor descriptor validation
Transform(TransformError)TransformErrorExtrapolation, stale data
Timeout(TimeoutError)TimeoutErrorOperation exceeded time limit
Internal { message, file, line }Internal errors with source location
Contextual { message, source }Error with preserved source chain

Creating Errors

Using Constructors

Error provides convenience constructors for the most common variants:

use horus::prelude::*;

// Configuration error
let err = Error::config("Invalid frequency: must be positive");

// Node error with context (takes node name + message)
let err = Error::node("MotorController", "Failed to initialize PWM");

// Network fault (communication sub-type)
let err = Error::network_fault("192.168.1.100", "Connection refused");

// mDNS failure (communication sub-type)
let err = Error::mdns_failed("register", "Avahi daemon not running");

// Internal error (prefer horus_internal! macro for file/line capture)
let err = horus_internal!("Unexpected state reached");

Using Variants Directly

For errors without convenience constructors, construct the sub-error directly:

use horus::prelude::*;
use horus::error::{ResourceError, NotFoundError, CommunicationError};

let err = Error::Resource(ResourceError::PermissionDenied {
    resource: "/dev/ttyUSB0".into(),
    required_permission: "read/write".into(),
});

let err = Error::Resource(ResourceError::AlreadyExists {
    resource_type: "session".into(),
    name: "main".into(),
});

let err = Error::NotFound(NotFoundError::Topic {
    name: "cmd_vel".into(),
});

Internal Errors with Source Location

Use the horus_internal!() macro to create internal errors that automatically capture file and line number:

use horus::prelude::*;

// Captures file/line automatically
return Err(horus_internal!("Unexpected state: {:?}", state));
// Produces: Internal { message: "Unexpected state: ...", file: "src/foo.rs", line: 42 }

Contextual Errors with Source Chain

Use Error::Contextual to wrap errors with additional context while preserving the original error chain:

use horus::prelude::*;

let config = load_file("robot.yaml")
    .map_err(|e| Error::Contextual {
        message: "Failed to load robot configuration".to_string(),
        source: Box::new(e),
    })?;
// Produces: "Failed to load robot configuration\n  Caused by: <original error>"

Error Propagation

Using the ? Operator

use horus::prelude::*;

fn load_robot_config(path: &str) -> Result<Config> {
    // File I/O errors automatically convert to Error::Io
    let content = std::fs::read_to_string(path)?;

    // JSON errors automatically convert to Error::Serialization
    let config: Config = serde_json::from_str(&content)?;

    Ok(config)
}

Automatic Conversions

Error implements From for many common error types:

Source TypeTarget Variant
std::io::ErrorError::Io
serde_json::ErrorError::Serialization
serde_yaml::ErrorError::Serialization
toml::de::ErrorError::Config
toml::ser::ErrorError::Serialization
std::num::ParseIntErrorError::Parse
std::num::ParseFloatErrorError::Parse
std::str::ParseBoolErrorError::Parse
uuid::ErrorError::Internal
std::sync::PoisonError<T>Error::Internal
Box<dyn std::error::Error>Error::Internal
Box<dyn std::error::Error + Send + Sync>Error::Contextual
anyhow::ErrorError::Internal

Error Checking

Pattern Matching

use horus::prelude::*;
use horus::error::{NotFoundError, NodeError, ResourceError};

match result {
    Ok(value) => process(value),
    Err(Error::NotFound(NotFoundError::Topic { name })) => {
        eprintln!("Topic not found: {}", name);
    }
    Err(Error::Node(node_err)) => {
        eprintln!("Node error: {}", node_err);
    }
    Err(Error::Resource(ResourceError::PermissionDenied { resource, .. })) => {
        eprintln!("Permission denied: {}", resource);
    }
    Err(Error::Internal { message, file, line }) => {
        eprintln!("Internal error at {}:{}: {}", file, line, message);
    }
    Err(e) => {
        eprintln!("Unexpected error: {}", e);
        if let Some(hint) = e.help() {
            eprintln!("  hint: {}", hint);
        }
    }
}

Best Practices

1. Use Specific Error Types

// Good: Specific error with context
return Err(Error::node("IMU", "I2C read failed on register 0x3B"));

// Avoid: Internal without context
return Err(horus_internal!("something went wrong"));

2. Add Context When Propagating

fn initialize_sensor() -> Result<()> {
    open_i2c_bus().map_err(|e| {
        Error::node("IMU", format!("Failed to open I2C: {}", e))
    })?;

    Ok(())
}

3. Handle Expected Errors Gracefully

fn get_config() -> Result<Config> {
    match load_config_file("config.yaml") {
        Ok(config) => Ok(config),
        Err(Error::NotFound(_)) => {
            // Expected: use defaults
            Ok(Config::default())
        }
        Err(e) => Err(e),  // Propagate unexpected errors
    }
}

4. Log Errors Before Propagating

use horus::prelude::*;

fn critical_operation() -> Result<()> {
    match do_something_important() {
        Ok(result) => Ok(result),
        Err(e) => {
            hlog!(error, "Critical operation failed: {}", e);
            Err(e)
        }
    }
}

Node Error Handling

In Tick Methods

impl Node for MyNode {
    fn tick(&mut self) {
        // Handle errors in tick - don't propagate
        if let Err(e) = self.process_data() {
            hlog!(error, "Processing failed: {}", e);
            // Optionally publish status
            self.publish_error_status(e);
        }
    }
}

Initialization Errors

impl MyNode {
    pub fn new(config: Config) -> Result<Self> {
        let driver = config.driver.connect().map_err(|e| {
            Error::node("MyNode", format!("Driver init failed: {}", e))
        })?;

        Ok(Self { driver })
    }
}

Graceful Degradation

fn read_sensor(&mut self) -> Option<SensorData> {
    match self.backend.read() {
        Ok(data) => Some(data),
        Err(e) => {
            self.error_count += 1;
            if self.error_count > 10 {
                hlog!(error, "Sensor failing repeatedly: {}", e);
            }
            None  // Return None instead of crashing
        }
    }
}

Testing Error Handling

#[cfg(test)]
mod tests {
    use super::*;
    use horus::prelude::*;

    #[test]
    fn test_returns_not_found_for_missing_file() {
        let result = load_config("nonexistent.yaml");
        assert!(matches!(result, Err(Error::NotFound(_))));
    }

    #[test]
    fn test_returns_config_error_for_invalid_yaml() {
        let result = parse_config("invalid: [yaml");
        assert!(matches!(result, Err(Error::Config(_))));
    }

    #[test]
    fn test_error_context() {
        let err = Error::node("TestNode", "test message");
        let display = format!("{}", err);
        assert!(display.contains("TestNode"));
        assert!(display.contains("test message"));
    }
}

Integration with anyhow

For applications that prefer anyhow:

use anyhow::{Context, Result as AnyhowResult};
use horus::prelude::*;

fn load_robot() -> AnyhowResult<Robot> {
    let config = load_config("robot.yaml")
        .context("Failed to load robot configuration")?;

    let robot = Robot::from_config(config)
        .context("Failed to create robot from config")?;

    Ok(robot)
}

// Convert back to horus::Result if needed
fn horus_function() -> Result<Robot> {
    load_robot().map_err(|e| Error::from(e))
}

HorusError Variants Reference

The HorusError enum (aliased as Error) is #[non_exhaustive] and wraps structured sub-error types:

VariantWrapsExample sub-variants
Io(std::io::Error)std I/O error
Config(ConfigError)Config parsing/validationMissingField, ParseFailed, Other
Communication(CommunicationError)IPC, topics, networkTopicFull, TopicNotFound, NetworkFault, MdnsFailed
Node(NodeError)Node lifecycleInitPanic, InitFailed, TickFailed, Other { node, message }
Memory(MemoryError)SHM, tensor poolsPoolExhausted, ShmCreateFailed, MmapFailed, AllocationFailed
Serialization(SerializationError)Serde errorsJson, Yaml, Toml, Binary
NotFound(NotFoundError)Missing resourcesFrame, Topic, Node, Service, Parameter
Resource(ResourceError)Resource lifecycleAlreadyExists, PermissionDenied, Unsupported
InvalidInput(ValidationError)Input validationOutOfRange, InvalidFormat, InvalidEnum, MissingRequired
Parse(ParseError)Parsing failuresInt, Float, Bool, Custom
InvalidDescriptor(String)Tensor descriptor
Transform(TransformError)TF errorsExtrapolation, StaleData
Timeout(TimeoutError)Timeouts
Internal { message, file, line }Debug errors
Contextual { message, source }Error chains

Sub-Error Variant Details

ConfigError

VariantFieldsSeverity
ParseFailedformat: &'static str, reason: StringPermanent
MissingFieldfield: String, context: Option<String>Permanent
ValidationFailedfield: String, expected: String, actual: StringPermanent
InvalidValuekey: String, reason: StringPermanent
Other(String)error messagePermanent

CommunicationError

VariantFieldsSeverity
TopicFulltopic: StringTransient
TopicNotFoundtopic: StringPermanent
TopicCreationFailedtopic: String, reason: StringPermanent
MdnsFailedoperation: String, reason: StringTransient
NetworkFaultpeer: String, reason: StringTransient
SerializationFailedreason: StringPermanent
ActionFailedreason: StringPermanent

NodeError

VariantFieldsSeverity
InitPanicnode: StringFatal
ReInitPanicnode: StringFatal
ShutdownPanicnode: StringPermanent
InitFailednode: String, reason: StringPermanent
TickFailednode: String, reason: StringPermanent
Othernode: String, message: StringPermanent

MemoryError

VariantFieldsSeverity
PoolExhaustedreason: StringTransient
AllocationFailedreason: StringPermanent
ShmCreateFailedpath: String, reason: StringPermanent
MmapFailedreason: StringPermanent
DLPackImportFailedreason: StringPermanent
OffsetOverflow(no fields)Permanent

SerializationError

VariantFieldsSeverity
Jsonsource: serde_json::ErrorPermanent
Yamlsource: serde_yaml::ErrorPermanent
Tomlsource: toml::ser::ErrorPermanent
Otherformat: String, reason: StringPermanent

NotFoundError

VariantFieldsSeverity
Framename: StringPermanent
ParentFramename: StringPermanent
Topicname: StringPermanent
Nodename: StringPermanent
Servicename: StringPermanent
Actionname: StringPermanent
Parametername: StringPermanent
Otherkind: String, name: StringPermanent

ResourceError

VariantFieldsSeverity
AlreadyExistsresource_type: String, name: StringPermanent
PermissionDeniedresource: String, required_permission: StringPermanent
Unsupportedfeature: String, reason: StringPermanent

ValidationError

VariantFieldsSeverity
OutOfRangefield: String, min: String, max: String, actual: StringPermanent
InvalidFormatfield: String, expected_format: String, actual: StringPermanent
InvalidEnumfield: String, valid_options: String, actual: StringPermanent
MissingRequiredfield: StringPermanent
ConstraintViolationfield: String, constraint: StringPermanent
InvalidValuefield: String, value: String, reason: StringPermanent
Conflictfield_a: String, field_b: String, reason: StringPermanent
Other(String)error messagePermanent

ParseError

VariantFieldsSeverity
Intinput: String, source: ParseIntErrorPermanent
Floatinput: String, source: ParseFloatErrorPermanent
Boolinput: String, source: ParseBoolErrorPermanent
Customtype_name: String, input: String, reason: StringPermanent

TransformError

VariantFieldsSeverity
Extrapolationframe: String, requested_ns: u64, oldest_ns: u64, newest_ns: u64Permanent
Staleframe: String, age: Duration, threshold: DurationTransient

TimeoutError (struct)

FieldType
resourceString
elapsedDuration
deadlineOption<Duration>

Severity: Transient

All sub-error enums are #[non_exhaustive] — new variants may be added in future releases.

Constructing Errors

// Named constructors (4 available)
Error::config("Invalid YAML syntax");
Error::node("SensorNode", "Sensor not responding");
Error::network_fault("192.168.1.100", "Connection refused");
Error::mdns_failed("register", "Avahi not running");

// Internal errors (captures file and line automatically)
horus_internal!("Unexpected state: {:?}", state);

// Contextual errors (wrapping another error)
Error::Contextual {
    message: "Failed to initialize sensor".into(),
    source: Box::new(io_error),
};

Type Aliases

pub type HorusResult<T> = Result<T, HorusError>;
pub type Result<T> = HorusResult<T>;  // Convenience alias
pub type Error = HorusError;          // Short name

Rate and Stopwatch Utilities

Rate

Drift-compensated rate limiter for controlling loop frequency:

use horus::prelude::*;

let mut rate = Rate::new(100.0); // Target 100 Hz
loop {
    do_work();
    rate.sleep(); // Sleeps for the remainder of the 10ms period
}

// Check actual performance
println!("Actual: {:.1} Hz", rate.actual_hz());
println!("Late: {}", rate.is_late());
MethodDescription
Rate::new(hz)Create targeting hz frequency
rate.sleep()Sleep for remainder of current period (drift-compensated)
rate.actual_hz()Exponentially smoothed actual frequency
rate.target_hz()Configured target frequency
rate.period()Target period as Duration
rate.reset()Reset cycle start (after long pauses)
rate.is_late()Whether current cycle exceeded target

Stopwatch

Simple elapsed time tracker:

use horus::prelude::*;

let mut sw = Stopwatch::start();
expensive_operation();
println!("Took {:.2} ms", sw.elapsed_ms());

// Lap: return elapsed and reset
let lap_time = sw.lap();
MethodDescription
Stopwatch::start()Create and start immediately
sw.elapsed()Elapsed time as Duration
sw.elapsed_us()Elapsed microseconds (u64)
sw.elapsed_ms()Elapsed milliseconds (f64)
sw.reset()Reset to zero
sw.lap()Return elapsed and reset

Retry Configuration

RetryConfig

Configuration for automatic retry of transient errors with exponential backoff:

use horus::prelude::*;

// Default: 3 retries, 10ms initial backoff, 2x multiplier, 1s cap
let config = RetryConfig::default();

// Custom
let config = RetryConfig::new(5, 20_u64.ms())
    .with_max_backoff(500_u64.ms())
    .with_multiplier(1.5);
MethodReturnsDescription
RetryConfig::new(max_retries, initial_backoff)SelfCreate with 2x multiplier and 1s cap
.with_max_backoff(duration)SelfSet maximum backoff duration
.with_multiplier(f64)SelfSet backoff multiplier (must be positive and finite)
max_retries()u32Maximum retry attempts
initial_backoff()DurationInitial backoff before first retry
max_backoff()DurationMaximum backoff cap
backoff_multiplier()f64Multiplier applied after each retry

Default values: 3 retries, 10ms initial backoff, 2x multiplier, 1s max backoff.

retry_transient()

Generic retry function that only retries transient errors:

use horus::prelude::*;

let config = RetryConfig::new(3, 10_u64.ms());

let result = retry_transient(&config, || {
    some_operation_that_may_fail()
})?;

Signature:

pub fn retry_transient<T, F>(config: &RetryConfig, f: F) -> HorusResult<T>
where
    F: FnMut() -> HorusResult<T>,

Behavior:

  • Calls f() up to max_retries + 1 times (initial attempt + retries)
  • Only Severity::Transient errors trigger retry (with exponential backoff)
  • Severity::Permanent and Severity::Fatal errors propagate immediately

Error Severity

Each error variant has an associated severity that determines retry behavior:

SeverityRetry?Examples
TransientYesTopicFull, NetworkFault, PoolExhausted, Timeout, Stale
PermanentNoTopicNotFound, MissingField, PermissionDenied, InitFailed
FatalNoInternal, Io

retry_transient and ServiceClient::call_resilient both use this severity classification.


See Also