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 forHorusError)Result<T>- Alias forstd::result::Result<T, Error>(short alias forHorusResult<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
| Variant | Description |
|---|---|
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 Type | Target Variant |
|---|---|
std::io::Error | Error::Io |
serde_json::Error | Error::Serialization |
serde_yaml_ng::Error | Error::Serialization |
toml::de::Error | Error::Config |
toml::ser::Error | Error::Serialization |
std::num::ParseIntError | Error::Parse |
std::num::ParseFloatError | Error::Parse |
std::str::ParseBoolError | Error::Parse |
uuid::Error | Error::Contextual |
std::sync::PoisonError<T> | Error::Internal |
Box<dyn std::error::Error> | Error::Internal |
Box<dyn std::error::Error + Send + Sync> | Error::Contextual |
anyhow::Error | Error::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-error | Variants |
|---|---|
ConfigError | ParseFailed { format, reason, source }, MissingField { field, context }, ValidationFailed { field, expected, actual }, InvalidValue { key, reason }, Other(String) |
CommunicationError | TopicFull { topic }, TopicNotFound { topic }, TopicCreationFailed { topic, reason }, NetworkFault { peer, reason }, SerializationFailed { reason }, ActionFailed { reason } |
NodeError | InitPanic { node }, ReInitPanic { node }, ShutdownPanic { node }, InitFailed { node, reason }, TickFailed { node, reason }, Other { node, message } |
MemoryError | PoolExhausted { reason }, AllocationFailed { reason }, ShmCreateFailed { path, reason }, MmapFailed { reason }, OffsetOverflow |
SerializationError | Json { source }, Yaml { source }, Toml { source }, Other { format, reason } |
NotFoundError | Frame { name }, ParentFrame { name }, Topic { name }, Node { name }, Service { name }, Action { name }, Parameter { name }, Other { kind, name }, WithSuggestion { kind, name, suggestion } |
ResourceError | AlreadyExists { resource_type, name }, PermissionDenied { resource, required_permission }, Unsupported { feature, reason } |
ValidationError | OutOfRange { 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) |
ParseError | Int { input, source }, Float { input, source }, Bool { input, source }, Custom { type_name, input, reason } |
TransformError | Extrapolation { frame, requested_ns, oldest_ns, newest_ns }, Stale { frame, age, threshold } |
TimeoutError | A 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
- Core Concepts - Understanding HORUS nodes
- API Reference - Core API, including the
Errortypes and theRate/Stopwatchloop-timing helpers - Troubleshooting - Common issues and solutions