Robust error handling is critical for production connectors. This guide covers error handling patterns, propagation strategies, and best practices for Strike48 connector development.
Philosophy
Good error handling should:
- Be explicit - Errors are values, not exceptions
- Provide context - Include what went wrong and why
- Enable recovery - Distinguish recoverable from fatal errors
- Aid debugging - Include actionable information
- Be type-safe - Use Rust's type system for correctness
Error Crate Ecosystem
| Crate | Purpose | When to Use |
|---|---|---|
| thiserror | Define custom error types | Library code, structured errors |
| anyhow | Error propagation with context | Application code, rapid prototyping |
| std::error::Error | Error trait | When not using external crates |
Recommendation: Use thiserror for your connector's error types, anyhow for application-level error handling.
Defining Error Types with thiserror
Basic Error Enum
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConnectorError {
#[error("Invalid target: {0}")]
InvalidTarget(String),
#[error("Connection timeout after {timeout}s")]
Timeout { timeout: u64 },
#[error("Tool '{tool}' not found in PATH")]
ToolNotFound { tool: String },
#[error("Command execution failed: {0}")]
ExecutionFailed(String),
#[error("Failed to parse output: {0}")]
ParseError(String),
}
pub type Result<T> = std::result::Result<T, ConnectorError>;
Error Wrapping
Wrap standard library errors:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConnectorError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Invalid UTF-8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
}
The #[from] attribute automatically implements From<OriginalError> for conversion.
Structured Error Information
Include structured data in errors:
#[derive(Error, Debug)]
pub enum ScanError {
#[error("Port {port} on {target} is filtered")]
PortFiltered {
target: String,
port: u16,
},
#[error("Scan of {target} failed after {attempts} attempts")]
ScanFailed {
target: String,
attempts: u32,
last_error: String,
},
#[error("Rate limit exceeded: {requests} requests in {window}s")]
RateLimitExceeded {
requests: u32,
window: u64,
retry_after: Option<u64>,
},
}
Error Propagation
The ? Operator
Use ? for automatic error propagation:
use std::fs;
pub fn read_config(path: &str) -> Result<Config> {
// ? automatically converts errors via From trait
let content = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
Manual Error Conversion
When automatic conversion isn't available:
pub fn validate_port(port_str: &str) -> Result<u16> {
port_str.parse::<u16>()
.map_err(|_| ConnectorError::InvalidPort {
value: port_str.to_string(),
reason: "Must be between 1 and 65535".to_string(),
})
}
Adding Context with map_err
Add context while propagating:
use std::fs;
pub fn load_targets(path: &str) -> Result<Vec<String>> {
fs::read_to_string(path)
.map_err(|e| ConnectorError::ConfigError {
file: path.to_string(),
reason: format!("Failed to read file: {}", e),
})?
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.pipe(Ok)
}
Error Hierarchies
Layered Error Types
Organize errors by domain:
// Network layer errors
#[derive(Error, Debug)]
pub enum NetworkError {
#[error("Connection refused to {host}:{port}")]
ConnectionRefused { host: String, port: u16 },
#[error("DNS lookup failed for {hostname}: {source}")]
DnsError { hostname: String, source: String },
#[error("TLS handshake failed: {0}")]
TlsError(String),
}
// Scanner layer errors
#[derive(Error, Debug)]
pub enum ScannerError {
#[error("Invalid scan configuration: {0}")]
InvalidConfig(String),
#[error("Scan timeout after {0}s")]
Timeout(u64),
#[error("Network error: {0}")]
Network(#[from] NetworkError),
}
// Top-level connector errors
#[derive(Error, Debug)]
pub enum ConnectorError {
#[error("Scanner error: {0}")]
Scanner(#[from] ScannerError),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("Prospector Studio communication error: {0}")]
Studio(#[from] strike48_connector_sdk::Error),
}
Flattening Error Chains
Sometimes you want to flatten nested errors:
impl From<NetworkError> for ConnectorError {
fn from(err: NetworkError) -> Self {
match err {
NetworkError::ConnectionRefused { host, port } => {
ConnectorError::ConnectionFailed(format!("{}:{}", host, port))
}
NetworkError::DnsError { hostname, .. } => {
ConnectorError::InvalidTarget(hostname)
}
NetworkError::TlsError(msg) => {
ConnectorError::SecurityError(msg)
}
}
}
}
Using anyhow for Application Code
Quick Error Handling
For application-level code (main.rs, bin files):
use anyhow::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
let config = load_config()
.context("Failed to load configuration")?;
let connector = create_connector(&config)
.context("Failed to initialize connector")?;
connector.run()
.await
.context("Connector execution failed")?;
Ok(())
}
fn load_config() -> Result<Config> {
let path = std::env::var("CONFIG_PATH")
.context("CONFIG_PATH not set")?;
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {}", path))?;
let config: Config = toml::from_str(&content)
.context("Invalid TOML configuration")?;
Ok(config)
}
Error Context Stack
anyhow builds a context stack:
use anyhow::{Context, Result};
pub async fn scan_network(subnet: &str) -> Result<Vec<Host>> {
let targets = expand_subnet(subnet)
.context("Failed to expand subnet")?;
let mut results = Vec::new();
for target in targets {
let host = scan_host(&target)
.await
.with_context(|| format!("Failed to scan {}", target))?;
results.push(host);
}
Ok(results)
}
Error output:
Error: Failed to scan 192.168.1.100
Caused by:
0: Connection timeout
1: Failed to connect to 192.168.1.100:22
2: Connection refused
Recoverable vs Fatal Errors
Distinguishing Error Types
#[derive(Error, Debug)]
pub enum ScanError {
// Recoverable - can retry or skip
#[error("Target {0} is unreachable")]
Unreachable(String),
#[error("Port {0} is filtered")]
Filtered(u16),
// Fatal - should stop execution
#[error("Invalid scan configuration: {0}")]
InvalidConfig(String),
#[error("Scanner tool not found: {0}")]
ToolMissing(String),
}
impl ScanError {
pub fn is_recoverable(&self) -> bool {
matches!(self, Self::Unreachable(_) | Self::Filtered(_))
}
pub fn is_fatal(&self) -> bool {
!self.is_recoverable()
}
}
Handling Recoverable Errors
pub async fn scan_targets(targets: Vec<String>) -> Result<ScanResults> {
let mut successful = Vec::new();
let mut failed = Vec::new();
for target in targets {
match scan_target(&target).await {
Ok(result) => successful.push(result),
Err(e) if e.is_recoverable() => {
tracing::warn!("Scan failed for {}: {}", target, e);
failed.push((target, e.to_string()));
}
Err(e) => {
// Fatal error - propagate immediately
return Err(e);
}
}
}
Ok(ScanResults { successful, failed })
}
Error Logging
Structured Logging with tracing
use tracing::{error, warn, info, debug};
pub async fn execute_scan(target: &str) -> Result<ScanResult> {
info!(target = %target, "Starting scan");
match perform_scan(target).await {
Ok(result) => {
info!(
target = %target,
open_ports = result.open_ports.len(),
duration_ms = result.duration_ms,
"Scan completed successfully"
);
Ok(result)
}
Err(e) if e.is_recoverable() => {
warn!(
target = %target,
error = %e,
"Scan failed but recoverable"
);
Err(e)
}
Err(e) => {
error!(
target = %target,
error = %e,
error_debug = ?e,
"Scan failed fatally"
);
Err(e)
}
}
}
Logging Error Chains
Log the full error chain for debugging:
use tracing::error;
pub fn log_error_chain(err: &dyn std::error::Error) {
error!("Error: {}", err);
let mut source = err.source();
let mut level = 1;
while let Some(err) = source {
error!(" Caused by ({}): {}", level, err);
source = err.source();
level += 1;
}
}
// Usage
if let Err(e) = run_connector().await {
log_error_chain(&e);
}
Converting to SDK Errors
Mapping to Strike48 Error Codes
use strike48_connector_sdk::{ConnectorError, ErrorCode};
pub fn to_sdk_error(err: ScanError) -> ConnectorError {
match err {
ScanError::InvalidTarget(msg) => {
ConnectorError::new(ErrorCode::InvalidInput, &msg)
}
ScanError::Timeout(duration) => {
ConnectorError::new(
ErrorCode::Timeout,
&format!("Scan timed out after {}s", duration)
)
}
ScanError::ToolMissing(tool) => {
ConnectorError::new(
ErrorCode::SystemError,
&format!("Required tool not found: {}", tool)
)
}
ScanError::Network(e) => {
ConnectorError::new(ErrorCode::NetworkError, &e.to_string())
}
_ => {
ConnectorError::new(ErrorCode::ExecutionError, &err.to_string())
}
}
}
Implementing BaseConnector with Error Conversion
use strike48_connector_sdk::*;
impl BaseConnector for ScannerConnector {
fn execute(
&self,
request: serde_json::Value,
capability_id: Option<&str>,
) -> Pin<Box<dyn Future<Output = Result<serde_json::Value>> + Send>> {
Box::pin(async move {
// Internal Result type
let result: crate::Result<ScanResult> = match capability_id {
Some("quick-scan") => self.quick_scan(request).await,
Some("full-scan") => self.full_scan(request).await,
_ => Err(ScanError::UnknownCapability),
};
// Convert to SDK Result
result
.map(|r| serde_json::to_value(r).unwrap())
.map_err(|e| to_sdk_error(e))
})
}
}
Error Response Format
Structured Error Responses
Return structured error information to clients:
use serde::Serialize;
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub error_code: String,
pub details: Option<serde_json::Value>,
pub timestamp: String,
}
impl From<ScanError> for ErrorResponse {
fn from(err: ScanError) -> Self {
let (error_code, details) = match &err {
ScanError::Timeout { duration } => (
"SCAN_TIMEOUT",
Some(serde_json::json!({ "duration_seconds": duration }))
),
ScanError::InvalidTarget { target, reason } => (
"INVALID_TARGET",
Some(serde_json::json!({
"target": target,
"reason": reason
}))
),
_ => ("SCAN_ERROR", None),
};
ErrorResponse {
error: err.to_string(),
error_code: error_code.to_string(),
details,
timestamp: chrono::Utc::now().to_rfc3339(),
}
}
}
Retry Logic
Exponential Backoff
use tokio::time::{sleep, Duration};
pub async fn scan_with_retry(
target: &str,
max_attempts: u32,
) -> Result<ScanResult> {
let mut attempts = 0;
let mut delay = Duration::from_secs(1);
loop {
attempts += 1;
match scan_target(target).await {
Ok(result) => return Ok(result),
Err(e) if e.is_recoverable() && attempts < max_attempts => {
warn!(
target = %target,
attempt = attempts,
delay_ms = delay.as_millis(),
error = %e,
"Scan failed, retrying"
);
sleep(delay).await;
delay *= 2; // Exponential backoff
}
Err(e) => {
error!(
target = %target,
attempts = attempts,
error = %e,
"Scan failed after max attempts"
);
return Err(e);
}
}
}
}
Retry with Circuit Breaker
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct CircuitBreaker {
failure_count: Arc<RwLock<u32>>,
threshold: u32,
timeout: Duration,
}
impl CircuitBreaker {
pub fn new(threshold: u32, timeout: Duration) -> Self {
Self {
failure_count: Arc::new(RwLock::new(0)),
threshold,
timeout,
}
}
pub async fn call<F, T>(&self, f: F) -> Result<T>
where
F: Future<Output = Result<T>>,
{
let count = *self.failure_count.read().await;
if count >= self.threshold {
return Err(ScanError::CircuitOpen {
failures: count,
retry_after: self.timeout,
});
}
match f.await {
Ok(result) => {
*self.failure_count.write().await = 0;
Ok(result)
}
Err(e) => {
*self.failure_count.write().await += 1;
Err(e)
}
}
}
}
Validation Errors
Input Validation
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Field '{field}' is required")]
Required { field: String },
#[error("Field '{field}' has invalid format: {reason}")]
InvalidFormat { field: String, reason: String },
#[error("Field '{field}' value {value} is out of range [{min}, {max}]")]
OutOfRange {
field: String,
value: String,
min: String,
max: String,
},
}
pub fn validate_scan_request(req: &ScanRequest) -> Result<(), ValidationError> {
if req.target.is_empty() {
return Err(ValidationError::Required {
field: "target".to_string(),
});
}
if req.timeout == 0 || req.timeout > 3600 {
return Err(ValidationError::OutOfRange {
field: "timeout".to_string(),
value: req.timeout.to_string(),
min: "1".to_string(),
max: "3600".to_string(),
});
}
// Validate IP/hostname format
if !is_valid_target(&req.target) {
return Err(ValidationError::InvalidFormat {
field: "target".to_string(),
reason: "Must be valid IP address or hostname".to_string(),
});
}
Ok(())
}
Builder Pattern with Validation
pub struct ScanConfigBuilder {
target: Option<String>,
ports: Vec<u16>,
timeout: u64,
}
impl ScanConfigBuilder {
pub fn new() -> Self {
Self {
target: None,
ports: vec![],
timeout: 300,
}
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
pub fn ports(mut self, ports: Vec<u16>) -> Self {
self.ports = ports;
self
}
pub fn timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
pub fn build(self) -> Result<ScanConfig> {
let target = self.target.ok_or_else(|| {
ValidationError::Required { field: "target".to_string() }
})?;
if self.ports.is_empty() {
return Err(ValidationError::Required {
field: "ports".to_string(),
}.into());
}
Ok(ScanConfig {
target,
ports: self.ports,
timeout: self.timeout,
})
}
}
Best Practices
✅ Do
- Use thiserror for library code - Clear, structured error types
- Use anyhow for application code - Quick context addition
- Make errors actionable - Include what to do to fix it
- Log at appropriate levels - error!, warn!, info!, debug!
- Distinguish recoverable errors - Don't fail fast unnecessarily
- Include structured data - Make errors machine-readable
- Test error paths - Write tests for error scenarios
- Document error conditions - Use
/// # Errorsin doc comments
❌ Don't
- Don't panic in library code - Return Result instead
- Don't swallow errors - Always propagate or log
- Don't use unwrap/expect - Unless you're 100% certain
- Don't create generic errors - Be specific about what failed
- Don't log and return - Do one or the other, not both
- Don't include secrets in errors - Sanitize sensitive data
Example: Complete Error Module
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConnectorError {
// Input validation
#[error("Invalid target: {0}")]
InvalidTarget(String),
#[error("Invalid port range: {0}")]
InvalidPortRange(String),
// External tool errors
#[error("Tool '{tool}' not found - ensure {tool} is installed")]
ToolNotFound { tool: String },
#[error("Tool execution failed: {0}")]
ToolExecutionFailed(String),
// Network errors
#[error("Connection timeout after {timeout}s")]
Timeout { timeout: u64 },
#[error("Connection refused to {host}:{port}")]
ConnectionRefused { host: String, port: u16 },
// Parsing errors
#[error("Failed to parse output: {0}")]
ParseError(String),
// Wrapped errors
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl ConnectorError {
pub fn is_recoverable(&self) -> bool {
matches!(
self,
Self::Timeout { .. }
| Self::ConnectionRefused { .. }
)
}
}
pub type Result<T> = std::result::Result<T, ConnectorError>;
Next Steps
- Async Patterns with Tokio - Asynchronous programming best practices
- Testing Connectors - Test error scenarios
- Building Your First Connector - Apply error handling patterns