This guide provides quick reference patterns for safe error handling in the ecto_libsql Rust NIF code.
- Never use
unwrap()in production code - always handle errors explicitly - Always provide context - include descriptive strings in error messages
- Use the
?operator - let Rust's error propagation do the work - Return errors to Elixir - don't panic the BEAM VM
Use for locking standard Mutex<T>:
fn safe_lock<'a, T>(
mutex: &'a Mutex<T>,
context: &str,
) -> Result<MutexGuard<'a, T>, rustler::Error>Use for locking Arc<Mutex<T>> (shared connections):
fn safe_lock_arc<'a, T>(
arc_mutex: &'a Arc<Mutex<T>>,
context: &str,
) -> Result<MutexGuard<'a, T>, rustler::Error>// ❌ DON'T
let conn_map = CONNECTION_REGISTRY.lock().unwrap();
// ✅ DO
let conn_map = safe_lock(&CONNECTION_REGISTRY, "function_name conn_map")?;// ❌ DON'T
let result = client
.lock()
.unwrap()
.client
.lock()
.unwrap()
.query(sql, params)
.await;
// ✅ DO
let client_guard = safe_lock_arc(&client, "function_name client")?;
let conn_guard = safe_lock_arc(&client_guard.client, "function_name conn")?;
let result = conn_guard.query(sql, params).await;// ❌ DON'T
let client = conn_map.get(conn_id).unwrap();
// ✅ DO
let client = conn_map
.get(conn_id)
.ok_or_else(|| rustler::Error::Term(Box::new("Connection not found")))?;// ❌ DON'T
let mode_str = mode.atom_to_string().unwrap();
// ✅ DO
let mode_str = mode
.atom_to_string()
.map_err(|e| rustler::Error::Term(Box::new(format!("Invalid mode atom: {:?}", e))))?;When inside TOKIO_RUNTIME.block_on(async { ... }), you need to convert rustler::Error to the async block's error type:
// ❌ DON'T
TOKIO_RUNTIME.block_on(async {
let guard = safe_lock_arc(&client, "context")?; // Won't compile!
guard.query(sql, params).await
})
// ✅ DO
TOKIO_RUNTIME.block_on(async {
let guard = safe_lock_arc(&client, "context")
.map_err(|e| format!("{:?}", e))?;
guard
.query(sql, params)
.await
.map_err(|e| format!("{:?}", e))
})Always drop locks before entering async operations to avoid deadlocks:
// ✅ DO
let conn_map = safe_lock(&CONNECTION_REGISTRY, "function_name")?;
let client = conn_map.get(conn_id).cloned()
.ok_or_else(|| rustler::Error::Term(Box::new("Connection not found")))?;
drop(conn_map); // Release lock before async operation
TOKIO_RUNTIME.block_on(async {
// async work here
})// ❌ DON'T
TXN_REGISTRY.lock().unwrap().insert(trx_id.clone(), trx);
// ✅ DO
safe_lock(&TXN_REGISTRY, "function_name txn_registry")?
.insert(trx_id.clone(), trx);// ❌ DON'T
let trx = TXN_REGISTRY.lock().unwrap().remove(trx_id).unwrap();
// ✅ DO
let trx = safe_lock(&TXN_REGISTRY, "function_name txn_registry")?
.remove(trx_id)
.ok_or_else(|| rustler::Error::Term(Box::new("Transaction not found")))?;When returning values from async blocks that use locks:
// ✅ DO
let result = TOKIO_RUNTIME.block_on(async {
let client_guard = safe_lock_arc(&client, "last_insert_rowid client")?;
let conn_guard = safe_lock_arc(&client_guard.client, "last_insert_rowid conn")?;
Ok::<i64, rustler::Error>(conn_guard.last_insert_rowid())
})?;
Ok(result)Always include the function name and which lock/resource is being accessed:
// Pattern: "function_name resource_description"
safe_lock(&CONNECTION_REGISTRY, "query_args conn_map")?
safe_lock_arc(&client, "query_args client")?
safe_lock_arc(&client_guard.client, "query_args conn")?Make error messages actionable:
// ❌ DON'T - vague
Err(rustler::Error::Term(Box::new("Error")))
// ✅ DO - specific
Err(rustler::Error::Term(Box::new("Connection not found")))
Err(rustler::Error::Term(Box::new("Transaction not found")))
Err(rustler::Error::Term(Box::new(format!("Failed to connect: {}", e))))When writing a new NIF function:
- No
unwrap()calls in production code - All mutex locks use
safe_lockorsafe_lock_arc - Context strings provided for all locks
- Registry access uses
ok_or_elseinstead ofunwrap - Locks dropped before async operations
- Error types converted in async blocks
- Descriptive error messages
- Returns
NifResult<T>with proper error variants
// ❌ WRONG - type mismatch in async block
TOKIO_RUNTIME.block_on(async {
let guard = safe_lock_arc(&client, "context")?; // rustler::Error
guard.query(sql, params).await // libsql::Error
})
// ✅ RIGHT
TOKIO_RUNTIME.block_on(async {
let guard = safe_lock_arc(&client, "context")
.map_err(|e| format!("{:?}", e))?; // Convert to String
guard
.query(sql, params)
.await
.map_err(|e| format!("{:?}", e)) // Convert to String
})// ❌ WRONG - potential deadlock
let guard = safe_lock(®ISTRY, "context")?;
some_async_operation().await?;
guard.do_something();
// ✅ RIGHT
let data = {
let guard = safe_lock(®ISTRY, "context")?;
guard.get_data().cloned()
}; // guard dropped here
some_async_operation().await?;// ❌ WRONG
conn.lock().unwrap().client.lock().unwrap()
// ✅ RIGHT
let conn_guard = safe_lock_arc(&conn, "context conn")?;
let client_guard = safe_lock_arc(&conn_guard.client, "context client")?;Test code is allowed to use unwrap() for simplicity:
#[cfg(test)]
mod tests {
#[test]
fn test_something() {
let db = Builder::new_local("test.db").build().await.unwrap();
let conn = db.connect().unwrap();
// ... test code can use unwrap()
}
}Run both Rust and Elixir tests:
# Rust tests
cd native/ecto_libsql && cargo test
# Elixir tests
mix test
# Static analysis
cd native/ecto_libsql && cargo checkGolden Rule: If you see .unwrap() in production code, replace it with proper error handling using the patterns above. Your future self (and your users) will thank you!