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>)
  • HorusError - The same enum under its long name, for pattern matching on the error type
  • The structured sub-errors every variant carries: CommunicationError, ConfigError, MemoryError, NodeError, NotFoundError, ParseError, ResourceError, SerializationError, TimeoutError, TransformError, ValidationError
  • Helpers: HorusContext, retry_transient, RetryConfig, Severity

HorusResult<T> still exists in horus_core::error but is not in the prelude — import it explicitly with use horus::error::HorusResult; if you need it. New code should use Result<T>.

Core Error Types

Error

The main error type for all HORUS operations. Error is the short alias — the enum's full name is HorusError, and the two names refer to the same type. New code should use Error.

Error Variants

VariantDescription
Io(std::io::Error)File system and I/O errors (#[from] std::io::Error)
Config(ConfigError)Configuration parsing/validation
Communication(CommunicationError)IPC, topic, and network errors
Node(NodeError)Node lifecycle errors (tuple variant, not named fields)
Memory(MemoryError)Memory allocation and shared-memory errors
Serialization(SerializationError)Serialization/deserialization errors
NotFound(NotFoundError)Registry lookup failed (frame, topic, node, service, action, parameter)
Resource(ResourceError)Already exists, permission denied, or unsupported
InvalidInput(ValidationError)Invalid argument/input
Parse(ParseError)Parsing errors
InvalidDescriptor(String)Cross-process tensor descriptor validation failure
Transform(TransformError)Coordinate frame extrapolation or stale data
Timeout(TimeoutError)Operation exceeded its time limit
Internal { message, file, line }Internal errors with source location
Contextual { message, source }Error with preserved source chain

There is no flat PermissionDenied, AlreadyExists, or Unsupported variant on Error — those live under Resource(ResourceError). The enum is also #[non_exhaustive], so any match on it needs a _ arm.

Creating Errors

Using Constructors

Error provides exactly three convenience constructors — config, node, and network_fault:

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 (takes peer + reason)
let err = Error::network_fault("192.168.1.50:7447", "connection refused");

Using Variants Directly

Every other variant is built by naming its structured sub-error:

use horus::prelude::*;

let err = Error::Communication(CommunicationError::TopicNotFound {
    topic: "/cmd_vel".to_string(),
});

let err = Error::Memory(MemoryError::AllocationFailed {
    reason: "Failed to allocate 1GB for buffer".to_string(),
});

// Invalid input — a ValidationError, not a String
let err = Error::InvalidInput(ValidationError::OutOfRange {
    field: "speed".into(),
    min: "0".into(),
    max: "100".into(),
    actual: "150".into(),
});

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

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

let err = Error::Parse(ParseError::Custom {
    type_name: "YAML".to_string(),
    input: "robot.yaml".to_string(),
    reason: "invalid syntax at line 5".to_string(),
});

Internal Errors with Source Location

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

The macro is not re-exported by the prelude — import it explicitly with use horus::horus_internal;:

use horus::prelude::*;
use horus::horus_internal;

// 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_ng::ErrorError::Serialization
toml::de::ErrorError::Config
toml::ser::ErrorError::Serialization
std::num::ParseIntErrorError::Parse
std::num::ParseFloatErrorError::Parse
std::str::ParseBoolErrorError::Parse
uuid::ErrorError::Contextual
std::sync::PoisonError<T>Error::Internal
Box<dyn std::error::Error>Error::Internal
Box<dyn std::error::Error + Send + Sync>Error::Contextual
anyhow::ErrorError::Contextual

HORUS depends on serde_yaml_ng (aliased internally as serde_yaml), not the unmaintained upstream serde_yaml. The ? conversion only applies to serde_yaml_ng::Error. Rather than adding the crate to your own Cargo.toml, use the re-export — use horus::serde_yaml; — which guarantees you get the exact version HORUS's From impl is written against.

Error Checking

Pattern Matching

use horus::prelude::*;

match result {
    Ok(value) => process(value),
    Err(Error::NotFound(resource)) => {
        eprintln!("Resource not found: {}", resource);
    }
    // `Node` is a tuple variant wrapping a `NodeError` — match the sub-variant
    Err(Error::Node(NodeError::Other { node, message })) => {
        eprintln!("Node {} error: {}", node, message);
    }
    // ...or handle every node failure generically via Display
    Err(Error::Node(e)) => {
        eprintln!("Node error: {}", e);
    }
    Err(Error::Internal { message, file, line }) => {
        eprintln!("Internal error at {}:{}: {}", file, line, message);
    }
    Err(e) => {
        eprintln!("Unexpected error: {}", e);
    }
}

NodeError::Other is only the catch-all sub-variant. The lifecycle failures are separate: InitFailed { node, reason }, TickFailed { node, reason }, InitPanic { node }, ReInitPanic { node }, and ShutdownPanic { node }.

Because HorusError is #[non_exhaustive], the trailing Err(e) arm is required, not optional.

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),
        // A missing file surfaces as Error::Io, not Error::NotFound
        Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::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_io_error_for_missing_file() {
        // A missing file arrives as Error::Io — that is what `?` on std::fs produces
        let result = load_config("nonexistent.yaml");
        assert!(matches!(result, Err(Error::Io(_))));
    }

    #[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"));
    }
}

Assert on the variant the code actually produces. Error::NotFound comes from HORUS registry lookups — a missing frame, topic, node, service, action, or parameter — never from the filesystem, so a missing file is an Error::Io; inspect std::io::Error::kind() if you need to tell "no such file" from a permission failure. Likewise, a YAML failure that reaches you through ? is an Error::Serialization; Error::Config is what a loader returns when it maps the failure itself into ConfigError::ParseFailed (which is also what ? produces for TOML).

Integration with anyhow

For applications that prefer anyhow, add it first:

horus add anyhow --source crates.io
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 Reference

Sub-Error Variants

Almost every HorusError payload is a structured sub-error, not a String, and those sub-errors are what you actually pattern-match on. All of them are exported by the prelude, and every one of these enums is #[non_exhaustive], so match arms over them also need a _:

Sub-errorVariants
ConfigErrorParseFailed { format, reason, source }, MissingField { field, context }, ValidationFailed { field, expected, actual }, InvalidValue { key, reason }, Other(String)
CommunicationErrorTopicFull { topic }, TopicNotFound { topic }, TopicCreationFailed { topic, reason }, NetworkFault { peer, reason }, SerializationFailed { reason }, ActionFailed { reason }
NodeErrorInitPanic { node }, ReInitPanic { node }, ShutdownPanic { node }, InitFailed { node, reason }, TickFailed { node, reason }, Other { node, message }
MemoryErrorPoolExhausted { reason }, AllocationFailed { reason }, ShmCreateFailed { path, reason }, MmapFailed { reason }, OffsetOverflow
SerializationErrorJson { source }, Yaml { source }, Toml { source }, Other { format, reason }
NotFoundErrorFrame { name }, ParentFrame { name }, Topic { name }, Node { name }, Service { name }, Action { name }, Parameter { name }, Other { kind, name }, WithSuggestion { kind, name, suggestion }
ResourceErrorAlreadyExists { resource_type, name }, PermissionDenied { resource, required_permission }, Unsupported { feature, reason }
ValidationErrorOutOfRange { field, min, max, actual }, InvalidFormat { field, expected_format, actual }, InvalidEnum { field, valid_options, actual }, MissingRequired { field }, ConstraintViolation { field, constraint }, InvalidValue { field, value, reason }, Conflict { field_a, field_b, reason }, Other(String)
ParseErrorInt { input, source }, Float { input, source }, Bool { input, source }, Custom { type_name, input, reason }
TransformErrorExtrapolation { frame, requested_ns, oldest_ns, newest_ns }, Stale { frame, age, threshold }
TimeoutErrorA struct, not an enum: resource: String, elapsed: Duration, deadline: Option<Duration>

Constructing Errors

// Named constructors — this is the complete set
Error::config("Invalid YAML syntax")
Error::node("SensorNode", "Sensor not responding")
Error::network_fault("192.168.1.50:7447", "connection refused")

// Every other variant is built from its structured sub-error
Error::Communication(CommunicationError::TopicNotFound { topic: "/cmd_vel".into() })

// Internal errors (captures file and line automatically)
// Requires `use horus::horus_internal;` — not in the prelude
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> = std::result::Result<T, HorusError>;
pub type Result<T> = HorusResult<T>;  // Convenience alias
pub type Error = HorusError;          // Short name

See Also