Building Apps
Error Handling
Errors happen. Actor systems are designed to handle them gracefully through isolation, supervision, and explicit error handling in handlers.
Errors in Handlers
Handle expected errors within your handlers:
builder.mutate_on::<ProcessOrder>(|actor, envelope| {
let msg = envelope.message();
match validate_order(msg) {
Ok(order) => {
actor.model.orders.push(order);
Reply::ready()
}
Err(e) => {
tracing::warn!("Invalid order: {}", e);
Reply::ready() // Continue processing other messages
}
}
});
Signaling Errors to Other Actors
When another actor needs to know about failures, send error response messages:
#[acton_message]
struct ProcessPayment { amount: u64 }
#[acton_message]
struct PaymentSuccess { amount: u64 }
#[acton_message]
struct PaymentFailed { reason: String }
builder.mutate_on::<ProcessPayment>(|actor, envelope| {
let msg = envelope.message();
let reply_envelope = envelope.reply_envelope();
if msg.amount > actor.model.balance {
Reply::pending(async move {
reply_envelope.send(PaymentFailed {
reason: "Insufficient funds".into()
}).await;
})
} else {
actor.model.balance -= msg.amount;
Reply::pending(async move {
reply_envelope.send(PaymentSuccess {
amount: msg.amount
}).await;
})
}
});
The requesting actor handles both outcomes:
requester
.mutate_on::<PaymentSuccess>(|_actor, envelope| {
let amount = envelope.message().amount;
println!("Processed ${}", amount);
Reply::ready()
})
.mutate_on::<PaymentFailed>(|_actor, envelope| {
let reason = &envelope.message().reason;
println!("Payment failed: {}", reason);
Reply::ready()
});
Isolation
One of the actor model's strengths is failure isolation. When one actor fails, others continue:
// If worker_1 panics, worker_2 and worker_3 keep running
worker_1.send(DangerousTask).await;
worker_2.send(SafeTask).await; // Still works
worker_3.send(SafeTask).await; // Still works
Design Patterns
Fail Fast for Configuration Errors
Don't retry configuration or startup errors:
builder.before_start(|actor| async move {
let config = load_config().expect("Config required");
// Store in actor state via initialization pattern
});
Graceful Degradation
Log and continue when non-critical operations fail:
builder.mutate_on::<OptionalTask>(|actor, envelope| {
let msg = envelope.message();
match perform_optional_work(msg) {
Ok(_) => tracing::info!("Optional work completed"),
Err(e) => tracing::warn!("Optional work failed, continuing: {}", e),
}
Reply::ready()
});
Circuit Breaker
Track failures and stop sending to failing services:
#[acton_actor]
struct Caller {
failures: u32,
circuit_open: bool,
}
#[acton_message]
struct CallService { data: String }
#[acton_message]
struct ServiceSuccess;
#[acton_message]
struct ServiceFailed { reason: String }
builder.mutate_on::<CallService>(|actor, envelope| {
let reply_envelope = envelope.reply_envelope();
if actor.model.circuit_open {
return Reply::pending(async move {
reply_envelope.send(ServiceFailed {
reason: "Circuit open".into()
}).await;
});
}
let msg = envelope.message().clone();
match call_external_service(&msg) {
Ok(_) => {
actor.model.failures = 0;
Reply::pending(async move {
reply_envelope.send(ServiceSuccess).await;
})
}
Err(e) => {
actor.model.failures += 1;
if actor.model.failures >= 5 {
actor.model.circuit_open = true;
}
Reply::pending(async move {
reply_envelope.send(ServiceFailed {
reason: e.to_string()
}).await;
})
}
}
});
Logging
Always log before failures for debugging:
builder.mutate_on::<RiskyOperation>(|actor, envelope| {
let msg = envelope.message();
tracing::info!(operation = %msg.id, "Starting risky operation");
match perform_operation(msg) {
Ok(_) => {
tracing::info!(operation = %msg.id, "Operation succeeded");
Reply::ready()
}
Err(e) => {
tracing::error!(operation = %msg.id, error = %e, "Operation failed");
Reply::ready()
}
}
});
Summary
- Handle expected errors within handlers
- Send explicit success/failure response messages
- Rely on actor isolation for fault tolerance
- Log errors for debugging
- Consider patterns like circuit breakers for external services
Next
Testing Actors — Strategies for testing