Error Handling in miniextendr
This guide covers error handling patterns, panic safety, and best practices for robust R-Rust interop.
This guide covers error handling patterns, panic safety, and best practices for robust R-Rust interop.
πOverview
miniextendr handles three types of errors:
- Rust panics β Converted to R errors
- R errors (
Rf_error) β Rust destructors run, then R unwinds - Result errors β Can be returned as R values or converted to R errors
πPanics
πBasic Panic Handling
Rust panics in #[miniextendr] functions are automatically caught and converted to R errors:
#[miniextendr]
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Division by zero!");
}
a / b
}divide(10L, 0L)
# Error: Division by zero!πPanic with Destructors
RAII resources are properly cleaned up when panics occur:
#[miniextendr]
pub fn process_file(path: &str) -> i32 {
let _file = File::open(path)?; // RAII - closed on drop
let _lock = mutex.lock(); // RAII - released on drop
if some_condition {
panic!("Something went wrong!");
// _file and _lock are STILL dropped before R sees the error
}
42
}πHow It Works
miniextendr uses R_UnwindProtect to ensure proper cleanup:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β #[miniextendr] function called from R β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1. Enter R_UnwindProtect context β
β 2. Create Rust resources (RAII) β
β 3. Execute user code β
β βββ Success: return result β
β βββ Panic: catch_unwind catches it β
β β βββ Drop all Rust resources β
β β βββ Convert panic message to R error β
β βββ R error (longjmp): cleanup handler triggers β
β βββ Drop all Rust resources β
β βββ Continue R's unwind β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
πResult Types
πAutomatic Error Conversion
Return Result<T, E> for automatic error conversion. The R message is Eβs
Debug text, so a String error arrives quoted. With the serde feature on,
an E: Serialize + Display (String included) uses its Display text
instead:
#[miniextendr]
pub fn parse_number(s: &str) -> Result<i32, String> {
s.parse().map_err(|e| format!("Parse error: {}", e))
}parse_number("42") # 42
parse_number("abc") # Error: "Parse error: invalid digit found in string"
# (no quotes with the serde feature)πClassed errors for R handlers
By default an Err is a bare rust_error whose message is the errorβs
Debug rendering. Implement RConditionData on the error type (or return
RError) to raise a custom class vector plus structured fields (e$value)
that tryCatch() can dispatch on, while keeping ? composition in Rust. See
CONDITIONS.md.
πCustom Error Types
Any Debug error type works, and the message is its Debug text:
validate(101) below raises OutOfRange { min: 0, max: 100, got: 101 }, not
the Display text (only an E: Serialize + Display under the serde feature
uses Display). To raise the Display text, return an RError
(.map_err(|e| RError::new(e.to_string()))) or implement RConditionData
(see Classed errors above):
use std::fmt;
#[derive(Debug)]
pub enum MyError {
InvalidInput(String),
OutOfRange { min: i32, max: i32, got: i32 },
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::InvalidInput(s) => write!(f, "Invalid input: {}", s),
MyError::OutOfRange { min, max, got } =>
write!(f, "Value {} out of range [{}, {}]", got, min, max),
}
}
}
#[miniextendr]
pub fn validate(x: i32) -> Result<i32, MyError> {
if x < 0 || x > 100 {
Err(MyError::OutOfRange { min: 0, max: 100, got: x })
} else {
Ok(x)
}
}πUsing anyhow or thiserror
For complex error handling, use standard error crates:
// With thiserror
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error at line {line}: {message}")]
Parse { line: usize, message: String },
}
#[miniextendr]
pub fn load_data(path: &str) -> Result<Vec<f64>, DataError> {
let contents = std::fs::read_to_string(path)?; // Auto-converts io::Error
// ...
}// With anyhow
use anyhow::{Context, Result};
#[miniextendr]
pub fn process(path: &str) -> Result<i32> {
let data = std::fs::read_to_string(path)
.context("Failed to read input file")?;
// ...
}
πR Error API
πpanic!() for Errors
Use panic!() for unrecoverable errors. The #[miniextendr] framework catches panics
and converts them to structured R error conditions via the tagged-condition transport:
#[miniextendr]
pub fn validate_positive(x: i32) -> i32 {
if x < 0 {
panic!("x must be non-negative, got {}", x);
}
x
}
For recoverable errors, return Result<T, E>:
#[miniextendr]
pub fn check_input(x: i32) -> Result<i32, String> {
if x < 0 {
return Err("x must be positive".to_string());
}
Ok(x)
}
Note:
r_stop()exists internally but is not part of the public API. Ther_error!macro has been removed. Always usepanic!()orErr(...)instead.
πNever Call Rf_error / Rf_errorcall Directly
Calling Rf_error() or Rf_errorcall() from Rust is forbidden.
πWhy it is forbidden
Rf_error and Rf_errorcall raise an R error by executing a longjmp. That jump
unwinds the C call stack directly, bypassing every Rust stack frame between your code
and the setjmp point in Rβs evaluator. The consequence is that Rust destructors never
run β any heap allocations, file handles, locks, or Box<T> values held by those
frames leak or remain poisoned forever.
The R_UnwindProtect wrapper that miniextendr uses internally is designed specifically
to avoid this: it runs a cleanup handler before continuing Rβs unwind, which lets Rust
drop everything properly. But that protection only exists when the Rust code enters
R_UnwindProtect. Code that calls Rf_error directly skips the protection entirely.
πUse panic!() or Err(β¦) instead
Both alternatives route through the safe wrapper:
// Good: framework catches this and raises a simpleError on the R side
#[miniextendr]
pub fn check(x: i32) -> i32 {
if x < 0 {
panic!("x must be non-negative");
}
x
}
// Also good: Err is transported as a value, raised as simpleError in the R wrapper
#[miniextendr]
pub fn parse(s: &str) -> Result<i32, String> {
s.parse().map_err(|e| format!("parse failed: {e}"))
}
Both produce an R condition with class c("rust_error", "simpleError", "error", "condition") β
the same hierarchy as stop(). R code can catch them at any level:
tryCatch(check(-1L), error = function(e) cat("caught:", conditionMessage(e), "\n"))
# caught: x must be non-negativeπMXL300 lint
miniextendr-lint (MXL300) scans your crate for direct Rf_error() and Rf_errorcall()
call sites and emits a build-time warning. If you see MXL300, replace the call with
panic!() or Err(...).
To suppress a false positive on a single line (e.g., inside the framework itself):
// miniextendr-lint: suppress MXL300
unsafe { Rf_error(...) }πThe ~8-byte leak on R_ContinueUnwind
There is one documented, accepted cost in the unwind path: when an R error propagates
through with_r_unwind_protect, the R_ContinueUnwind call that hands
control back to Rβs evaluator leaks approximately 8 bytes (the RErrorMarker struct
plus its Box header). This is not a bug β it is a deliberate trade-off to keep the
unwind path free of further R API calls that could themselves error.
This cost does not apply to the normal Rust panic path, which returns to R via a
tagged SEXP value without any longjmp. If you see this leak in valgrind, it is
expected and bounded: one allocation per R error that crosses a with_r_unwind_protect
boundary, not per call.
πWarnings
Issue warnings without stopping execution:
use miniextendr_api::error::r_warning;
#[miniextendr]
pub fn risky_operation(x: i32) -> i32 {
if x > 1000 {
r_warning("Large value may cause performance issues");
}
expensive_computation(x)
}πConsole Output
Print to Rβs console (not stderr):
use miniextendr_api::{r_print, r_println};
#[miniextendr]
pub fn verbose_function() {
r_println!("Starting computation...");
// ... work ...
r_println!("Done!");
}
πTagged-Condition Transport
Rust-origin failures (panics, Result::Err, Option::None) are transported as
tagged SEXP values back to R, and the generated R wrapper inspects the value and raises a
structured R condition. This ensures all Rust destructors have fully completed before R sees
the error, and gives R code a typed condition class to catch.
This is the only transport for #[miniextendr] functions and methods. The
unwrap_in_r attribute (orthogonal to the transport) instead returns Result<T, E>
to R as a list with an $error slot β Err never traverses the condition pipeline.
πWhat Gets Caught
The transport intercepts three failure modes:
| Failure | Error kind | Example |
|---|---|---|
| Rust panic | "panic" | panic!("oops") |
Result::Err | "result_err" | Err("bad input".to_string()) |
Option::None | "none_err" | Returning None from -> Option<()> or -> Option<SEXP> (any other Option<T>, a methodβs Option<Self> included, converts None to NULL / NA) |
Not affected: Result<T, ()> (unit error type) always returns NULL on Err(()),
since a unit error is a deliberate sentinel, not a failure.
πThe Error Value (Rust Side)
When a failure occurs, the generated C wrapper calls make_rust_condition_value(message, kind, None, call) from
miniextendr_api::error_value. This builds a tagged SEXP β a named list with:
$error: the error message (character scalar)$kind: machine-readable kind ("panic","result_err","none_err","conversion")$class: optional user-supplied class name (orNULL)$call: the R call SEXP (orNULL)- class attribute:
"rust_condition_value" __rust_condition__attribute:TRUE
This value is returned as a normal SEXP from the .Call() interface. No R error is raised
at this point β all Rust stack frames have already unwound.
πThe R Wrapper (R Side)
The generated R wrapper checks the return value:
my_function <- function(x) {
.val <- .Call(C_mypkg_my_function, .call = sys.call(), x)
if (inherits(.val, "rust_error_value") && isTRUE(attr(.val, "__rust_error__"))) {
stop(structure(
class = c("rust_error", "simpleError", "error", "condition"),
list(message = .val$error, call = sys.call(), kind = .val$kind)
))
}
.val
}
The R condition has class c("rust_error", "simpleError", "error", "condition"), so it
can be caught at any level of the hierarchy.
πCatching Errors in R
Catch by the specific rust_error class:
tryCatch(
parse_data("not a number"),
rust_error = function(e) {
message("Rust error (", e$kind, "): ", e$message)
}
)
Or with tryCatch/withCallingHandlers using the generic error class:
tryCatch(
parse_data("bad"),
error = function(e) {
if (inherits(e, "rust_error")) {
# Structured Rust error with e$kind
} else {
# Other R error
}
}
)
The condition object has these fields:
| Field | Type | Description |
|---|---|---|
message | character | Human-readable error message |
call | call | The R call that triggered the error |
kind | character | "panic", "result_err", or "none_err" |
πWorks with All Class Systems
The tagged-condition transport applies to methods in any class system; no extra attribute is needed:
#[miniextendr] // env class (default)
impl Counter {
fn get(&self) -> i32 { self.value }
}
#[miniextendr(r6)] // R6 class
impl Widget {
pub fn name(&self) -> String { self.name.clone() }
}
#[miniextendr(s7)] // S7 class
impl Gauge {
pub fn read(&self) -> f64 { self.level }
}
It also works on trait impl methods:
#[miniextendr]
impl Fallible for MyType {
fn get_value(&self) -> i32 { self.value }
}πObject Survival After Errors
Objects remain valid after an error. Since the error is transported as a value (not a longjmp), the objectβs internal state is never corrupted:
counter <- Counter$new()
counter$inc()
# Error does not corrupt the object
tryCatch(counter$panic_method(), error = function(e) NULL)
counter$get() # Still returns 1πunwrap_in_r β Result-as-value opt-out
The default transport treats Result::Err as a Rust-origin failure (kind "result_err",
class rust_error). The unwrap_in_r attribute instead returns the Result<T, E> to
R as a list with an $error slot, leaving the caller to decide what to do:
| default | unwrap_in_r | |
|---|---|---|
Err(e) reaches R | as a rust_error condition (via stop()) | as list(value = NULL, error = e) |
| Catchable by class | Yes (tryCatch(..., rust_error = ...)) | n/a β no error is raised |
| Use case | Most code; cleanly maps Rust errors to R errors | Returning structured error data alongside values |
πPanic Hook and Backtraces
πThe Panic Hook
miniextendr installs a custom panic hook at package load time via miniextendr_panic_hook(),
which is called from R_init_<pkg>() in the C entrypoint. The hook is registered once using
std::sync::Once and controls whether Rust backtraces are printed to stderr on panic.
By default, backtraces are suppressed. Panic messages are still captured and forwarded to R as error messages, but the noisy default Rust backtrace output is hidden. This keeps R console output clean for end users.
πEnabling Backtraces
Set the MINIEXTENDR_BACKTRACE environment variable to see full Rust backtraces on panic:
Sys.setenv(MINIEXTENDR_BACKTRACE = "1")
my_function_that_panics()
# thread 'main' panicked at 'something went wrong', src/lib.rs:42:5
# stack backtrace:
# 0: std::panicking::begin_panic
# 1: mypackage::my_function
# ...
Accepted values: "1" or "true" (case-insensitive). Any other value or unset = backtraces
suppressed.
To disable again:
Sys.unsetenv("MINIEXTENDR_BACKTRACE")
This variable is read at panic time, not at hook registration time, so it can be toggled during a session without restarting R.
πHow It Works
The hook replaces Rustβs default panic hook with a wrapper that checks MINIEXTENDR_BACKTRACE
on every panic:
Panic occurs
-> Custom hook runs
-> Reads MINIEXTENDR_BACKTRACE env var
-> If "true" or "1": delegates to default hook (prints backtrace to stderr)
-> Otherwise: suppresses output (panic message still captured for R error)
The panic message itself is always captured by catch_unwind in the generated C wrapper and
forwarded to R β the hook only controls whether the backtrace is additionally printed to
stderr.
πPoisoning Recovery
If the panic hook installation itself panics (e.g., take_hook fails), Once marks the
state as poisoned. On the next call, the hook retries via call_once_force and prints a
warning:
warning: miniextendr panic hook is retrying after a previous failed attempt
This is a defensive measure β in practice, hook installation should not fail.
πThread Safety
πMain Thread Requirement
R API calls must happen on Rβs main thread. Checked FFI wrappers route calls from an active miniextendr worker context back to that thread and panic for arbitrary off-main callers in every build mode. Some lower-level SEXP accessors also retain debug assertions:
#[miniextendr]
pub fn my_function() {
// Runs directly here because default exports execute on R's main thread.
r_println!("Hello from R!");
}πThread Detection
Explicitly check thread context:
use miniextendr_api::worker::is_r_main_thread;
pub fn internal_function() {
if is_r_main_thread() {
r_println!("On main thread");
} else {
eprintln!("On worker thread");
}
}
πType Conversion Errors
A bad argument is caught in one of two places: by an R-side check in the
generated wrapper (the type and length checks most built-in argument types
get, no_na, inherits, not_inherits, and the match_arg / choices validation), or by
the Rust conversion (TryFromSexp) for what only Rust can judge, such as a
non-numeric string given to AsNumeric. The arguments without an R-side type
check convert in Rust directly: every custom TryFromSexp type,
Either<L, R>, the AsFromStr family, and every argument whose type checks
are dropped (Unchecked<T>, no_preconditions, the crateβs preconditions = false, or no-preconditions-default).
Both places raise the same error condition (#1591):
- the class vector
c("rust_error", "simpleError", "error", "condition"), preceded by the crateβsconversion_error_classwhen set (below), and, for a conversion whose error type implementsRConditionData, by that typeβs own classes first (below); e$kind == "conversion";e$param, the R name of the failing parameter;- a message in R terms. An R-side check says the one requirement that failed
(
'x' must have length 1,'x' must not contain NA,'mode' should be one of "fast", "slow"), or gives the package authorβs own message for aninheritscheck (which also covers that parameterβs type checks), anot_inheritscheck or ano_nacheck that sets one (MINIEXTENDR_ATTRIBUTE.md). Aninheritscheck can also pick its message by the refused valueβs class: awhen(class = "data.frame", message = "...")hint (MINIEXTENDR_ATTRIBUTE.md). A conversion says what the argument must be and why it is not:'<p>' must be <expected>: <reason>, orinvalid '<p>' argument: <reason>for an opaque custom type without an R-facing expectation; - on a conversion only,
e$rust_type, the Rust type as written in the signature, for the package author.
So one handler, rust_error or the crateβs class, catches every argument
error, whichever side found it:
#[miniextendr(internal)]
pub fn ratio(num: AsNumeric, den: AsNumeric) -> Option<f64> { Some(num.0? / den.0?) }
#[miniextendr(internal)]
pub fn peak(dv: AsNumericVec) -> Option<f64> { dv.0.into_iter().flatten().reduce(f64::max) }e1 <- tryCatch(ratio(c(1, 2), 3), error = identity) # R-side length check
class(e1)
#> [1] "rust_error" "simpleError" "error" "condition"
conditionMessage(e1)
#> [1] "'num' must have length 1"
e1$param
#> [1] "num"
e2 <- tryCatch(peak(c("1", "BLQ")), error = identity) # Rust conversion
class(e2)
#> [1] "rust_error" "simpleError" "error" "condition"
conditionMessage(e2)
#> [1] "'dv' must be numeric: non-numeric value(s): \"BLQ\" (element 2)"
e2$param
#> [1] "dv"
e2$rust_type
#> [1] "AsNumericVec"
conditionCall(e) is the same call whichever side refused the argument: the
wrapperβs call as written (what stopifnot() reports), or under
call = caller the callerβs call, or under call_arg the call passed as
.call (CALL_ATTRIBUTION.md).
no_na on a reading marker (AsNumeric*, AsCharacter*) runs on both
sides: the R guard refuses Rβs NA, and the C wrapper refuses what the
marker reads as missing after the conversion ("NA", a blank string, a
factor NA level). On an Either, only the C wrapper checks, for the arm
the value converted to
(MINIEXTENDR_ATTRIBUTE.md).
A handler sees one condition either way, with the same classes, e$param,
message and call:
peak_no_na <- function(dv) ... # #[miniextendr(no_na)] dv: AsNumericVec
e_r <- tryCatch(peak_no_na(c("1", NA)), error = identity) # R guard
e_rust <- tryCatch(peak_no_na(c("1", "NA")), error = identity) # after the conversion
identical(class(e_r), class(e_rust))
#> [1] TRUE
identical(conditionCall(e_r)[[1]], conditionCall(e_rust)[[1]])
#> [1] TRUE
conditionMessage(e_rust)
#> [1] "'dv' must not contain NA"
e_rust$rust_type
#> NULL
The R-side checks are one guard per check,
if (!isTRUE(<check>)) .miniextendr_arg_error("<p>", "<requirement>"),
calling a helper in the generated wrappers file only when the check fails;
a passing argument costs one isTRUE() test per check. A check with the
authorβs message passes it instead of the requirement,
.miniextendr_arg_error("<p>", message = "<message>"), and the helper uses
it as the condition message unchanged. The receiver check of an S7 method
registered for S7::class_any (s7(fallback)) raises the same condition on
x: 'x' must be an S7 object, got integer.
πConversion wording
The <expected> part comes from the same type table as the R-side checks
(with coerce widening it the same way), and the built-in conversion errors
(SexpError and its parts) give the <reason> in R terms rather than as
SEXPTYPE names:
| Argument type | <expected> |
|---|---|
i32 / f64 | a single integer / a single double (a single whole number / a single number under coerce) |
f32, i8, i16, i64, isize, AsNumeric | a single number |
u16, u32, u64, usize | a single non-negative number |
bool | TRUE or FALSE |
String, &str, char, PathBuf | a single string |
u8 / Rcomplex | a single raw value / a single complex value |
Vec<T> / &[T] | the R type: integer, double, numeric, logical, character, raw, complex, or integer or whole-number numeric |
AsNumericVec | numeric |
AsCharacter / AsCharacterVec | coercible to a single string / coercible to character |
AsFromStr<T> / AsFromStrVec<T> | a single string / character (what T is parsed from) |
Either<L, R> | <expected of L> or <expected of R> (a single integer or a single string), the same whichever arm got further. An arm with no entry here is named by what its type declares (a #[derive(TryFromSexp)] newtype), else by its own error at run time (numeric for a type error, the choices of a match_arg enum: one of "fast", "slow", or a single double), else by its @param noun (a `DocId` ) when the other arm is named. An Option<T> or Result<T, ()> arm reads NULL or <expected of T> |
(A, B, ...) | a list of length N |
DataFrame | a data frame |
a #[derive(TryFromSexp)] newtype | the inner typeβs (struct Dose(AsNumeric): a single number), declared by the derive; none for an inner type that has none |
a match_arg enum | none: a choice error is worded whole, as the R-side match_arg check words it ('mode' should be one of "fast", "slow", 'mode' must be of length 1, 'mode' must be NULL or a character vector), with or without #[miniextendr(match_arg)] (#1767). As an Either arm it is named one of "fast", "slow" |
HashMap, BTreeMap, NamedList, List | a list |
Option<T> | NULL or <expected of T> |
i64, u64, isize, usize under strict | a single whole number / a single non-negative whole number |
an opaque custom TryFromSexp type | none: invalid '<p>' argument: <reason> |
| Built-in error | <reason> |
|---|---|
| type | got character (expected integer, got character without an <expected>) |
class (a DataFrame given a non-list, or a list that is not a data frame) | got list (expected a data frame, got list without an <expected>) |
| length | got length 2 (expected length 1, got length 2 without an <expected>) |
| NA | NA is not allowed |
| invalid value | the valueβs own text, e.g. non-numeric value(s): "BLQ" (element 2) |
Either (both branches failed) | the reason of the branch that took the kind of value (got length 2, data.frame has no column names), else one for the valueβs type (got numeric) |
match_arg enum, as an Either arm | got "zzz", got numeric, got length 2, NA is not allowed |
A vector conversion checks every element and reports them together, each
reason once with the 1-based positions that failed with it, as R numbers them:
'xs' must be integer or whole-number numeric: NA is not allowed (element 1); value out of range (elements 2, 4). The first 10 failing elements are
listed, the rest counted (; and 5 more). The Rust type is in
e$rust_type, not in the message.
The examples below are for arguments without the R-side checks
(Unchecked<T>, no_preconditions, the crateβs preconditions = false,
no-preconditions-default), where these values reach Rust.
πType mismatch
#[miniextendr(no_preconditions)]
pub fn needs_integer(x: i32) -> i32 {
x * 2
}needs_integer("abc")
# Error: 'x' must be a single integer: got character
e <- tryCatch(needs_integer("abc"), error = identity)
e$kind # "conversion"
e$param # "x"
e$rust_type # "i32"
An Either<L, R> names both sides, and its reason is the one of the branch
that got furthest; the full Rust type is in e$rust_type:
either_int_or_str(3.5) # value: Either<i32, String>
# Error: 'value' must be a single integer or a single string: got numeric
either_int_or_str(1:2)
# Error: 'value' must be a single integer or a single string: got length 2
A list that is not a data frame is not a data frame at all, so a DataFrame
arm (or parameter) refuses it by its type: 'x' must be double or a data frame: got list. An arm is refused by its kind when it fails with a type or
class error, or when a match_arg enum is given a value that is not a string
or factor. When both arms are refused that way, the message names both, even
when the macro knows neither armβs expectation (an opaque type, or a
match_arg enum without #[miniextendr(match_arg)]):
either_route_or_number(TRUE) # x: Either<Route, f64>
# Error: 'x' must be one of "oral", "bolus", "infusion", or numeric: got logical
A match_arg / choices parameter on Either<T, R> is decoded differently
(βChoice or Another Valueβ in ENUMS_AND_FACTORS.md):
the R prelude checks character or factor input against the choices, and
anything else is converted to R alone. A value R refuses is refused
against the whole parameter, in the words of its @param line, and Rβs
error gives the reason:
choices_either_level(TRUE) # level: Either<String, f64>, choices("low", "mid", "high")
# Error: 'level' must be one of "low", "mid", "high", or a number: got logical
When one arm took the kind of value and failed later, its reason is the
messageβs: either_route_or_number("zzz") reads 'x' must be one of "oral", "bolus", "infusion", or a single double: got "zzz".
invalid '<p>' argument is left for a type whose expectation neither the
macro nor the error knows, such as an opaque custom type, with the errorβs own
message (its Display text, or its RConditionData message) as the reason.
πNA Handling
NA-rejecting scalar types such as i32 and bool report conversion errors;
their Option forms preserve NA as None:
#[miniextendr(no_preconditions)]
pub fn needs_value(x: i32) -> i32 { x }
#[miniextendr]
pub fn handles_na(x: Option<i32>) -> i32 {
x.unwrap_or(-1)
}needs_value(NA_integer_)
# Error: 'x' must be a single integer: NA is not allowed
handles_na(NA_integer_) # -1πCoercion Errors
When coercion fails:
#[miniextendr(coerce)]
pub fn needs_int(x: i32) -> i32 { x }needs_int(1.5) # Error: 'x' must be integer or whole-number numeric (R-side check)
needs_int(1e20) # Error: 'x' must be a single whole number: value out of rangeπClassed conversion errors
When the TryFromSexp::Error of an argument type implements
RConditionData,
a failed conversion takes that errorβs class vector, message and data, the
same way a Result<T, E: RConditionData> return does. kind stays
"conversion", the message keeps the parameter context, and e$param and
e$rust_type are added, so a package can put the conversion failures of its
own argument types in its condition family:
use miniextendr_api::RCondition;
use miniextendr_api::named_vector::NamedVector;
use miniextendr_api::{SEXP, TryFromSexp};
use std::collections::BTreeMap;
pub struct Hyperparams(BTreeMap<String, f64>);
#[derive(Debug, RCondition)]
#[condition(class = "pkg_error")]
pub enum HyperparamError {
#[condition(class = "pkg_error_bad_arg", message = "expected a named numeric vector ({reason})")]
NotNamedNumeric { reason: String },
#[condition(class = "pkg_error_negative", message = "hyperparameter '{param}' must be non-negative, got {value}")]
Negative { param: String, value: f64 },
}
impl TryFromSexp for Hyperparams {
type Error = HyperparamError;
fn try_from_sexp(sexp: SEXP) -> Result<Self, Self::Error> {
let NamedVector(map) = NamedVector::<BTreeMap<String, f64>>::try_from_sexp(sexp)
.map_err(|e| HyperparamError::NotNamedNumeric { reason: e.to_string() })?;
if let Some((param, value)) = map.iter().find(|(_, v)| **v < 0.0) {
return Err(HyperparamError::Negative { param: param.clone(), value: *value });
}
Ok(Hyperparams(map))
}
}
#[miniextendr]
pub fn hyperparams_total(hyper: Hyperparams) -> f64 {
hyper.0.values().sum()
}e <- tryCatch(hyperparams_total(1:3), error = identity)
class(e)
# [1] "pkg_error_bad_arg" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$kind # "conversion"
e$param # "hyper"
e$rust_type # "Hyperparams"
e$reason # "invalid value: NamedVector requires a names attribute on the input vector"
conditionMessage(e)
# invalid 'hyper' argument: expected a named numeric vector (invalid value: ...)
tryCatch(hyperparams_total("a"), pkg_error = function(e) "caught by the family handler")
Rules:
param/rust_typeprecedence. When the error typeβs owndata()already has a field namedparam(orrust_type), the typeβs value is kept and the framework adds none: the type author owns the fields they chose, and the parameterβs name stays in the message. Above,hyperparams_total(c(alpha = 1, beta = -2))givese$param == "beta"(the hyperparameter) and the messageinvalid 'hyper' argument: hyperparameter 'beta' must be non-negative, got -2.- Reserved names.
data()fields must not bemessage,callorkind; a reserved name raises a plainrust_errorexplaining the clash, as forResulterrors. - Fallback. An error type without the impl keeps the plain class vector.
The built-in
SexpErrorfamily andMatchArgErrorare reworded as in the table above; any other type is rendered withDisplay. The error type must implementRConditionDataorDisplay. - A newtypeβs refusal. The
Option/Vec/Vec<Option>conversions of a#[derive(TryFromSexp)]newtype never call its owntry_from_sexp, so a refusal written there would not hold for them. A newtype refuses values its inner type accepts with#[try_from_sexp(validate = path)], a check on the R value that every shape runs. It returns anRError(or anyRConditionDatatype), which becomesSexpError::Condition: theSexpErrorarm above then carries its classes and fields, with its message as the reason after the prefix, also from anEitherarm whose other arm refused the kind of value. For aVecof a newtype overList(a list of R objects) the check runs on each element, and the error reports every refused element with its position,(element 2), with the classes of every refusal. - The whole message. An error whose
RConditionData::argument_messageisSome(RError::argument_message(...),#[condition(argument_message = "β¦")]) gives the argument errorβs whole message, as aninherits(..., message = )guard does: no prefix, the classes,kind,e$param,e$rust_typeand the call as above. It holds for a typeβs ownTryFromSexperror and for avalidaterefusal underOption/Vec; inside anEithertheEitherwords the message. In a list of objects (Vecof a newtype overList) it is the reason of the element it refused, before that elementβs position, after the prefix. See EXTENDING_MINIEXTENDR.md. - Raising instead of returning.
rust_error!(class = ...)inside atry_from_sexpraises the functionβs error: its class and the call as written, butkind = "error", noe$param/e$rust_type, no crate class and no parameter in the message. Return the error, or refuse in a newtypeβs check, for an argument error. - Worker and
call = caller. The conversion runs on the main thread in both cases, so the class, data ande$paramare the same;conditionCall(e)follows the functionβs call attribution. - Sidecar setters (
Type_set_<field>, and the R6 / S7 bindings over them) raise the same condition on theirvalueformal, naming the field:'<field>' must be <expected>: <reason>('count' must be a number: got "abc",'tags' must be character: got integer), withe$param == "value"and the fieldβs Rust type ase$rust_type. The field is in the message because an R6 active bindingβs or S7 propertyβs condition call is the bindingβs own anonymous function, which does not name it. - Strict mode (
#[miniextendr(strict)]on lossy integer types) rejects a logical, raw, fractional, out-of-range orNAinput with the same condition ('n' must be a single whole number: got logical), every failing element of a vector at once. A strict return value that does not fit an R integer is still a panic (kind = "panic"): that is the functionβs fault, not the argumentβs.
πA crate-level class for every conversion error
To put every argument error of a package in its condition family without an
RConditionData impl on each type, name the classes once in the crateβs
Cargo.toml:
[package.metadata.miniextendr]
conversion_error_class = ["pkg_error_argument", "pkg_error"] # or one string: "pkg_error"
Every argument error of the crate then carries those classes before the
rust_error layering: the Rust conversions (free functions, methods, trait
methods and sidecar setters) and the R-side checks alike, so a package can
document one argument-error class for its users. An error type with its own
RConditionData classes keeps them first; the crate classes follow,
skipping any the error already names:
# A plain SexpError (e.g. an `i32` argument without a precondition), or any
# R-side check (a length check, `no_na`, `inherits`, `not_inherits`, a bad choice):
class(tryCatch(f("a"), error = identity))
# [1] "pkg_error_argument" "pkg_error" "rust_error" "simpleError" "error" "condition"
# An error type classed c("pkg_error_negative", "pkg_error"):
class(tryCatch(g(-1L), error = identity))
# [1] "pkg_error_negative" "pkg_error" "pkg_error_argument" "rust_error" "simpleError" "error" "condition"
Without the key nothing is added. The value is a string or a single-line
array of strings, set once; the entries must be non-empty and distinct and
must not name rust_error, simpleError, error or condition, which every
argument error already carries. The macro emits the classes into each Rust
conversion arm, and miniextendr_init! registers them for the generated
wrappers file, which binds them as .miniextendr_conversion_error_class for
the R-side checks (NULL without the key). A conversion arm reads the
manifest of the crate that defines its item, the wrappers-file binding that of
the crate calling miniextendr_init!; they agree whenever the packageβs
#[miniextendr] items live in that crate, the usual layout.
tests/cross-package/producer.pkg sets the key and tests it in
test-conversion-error-class.R.
A function body raises the same argument error, with these classes, through
arg_error!(param = "mode", "..."), and match_arg_param matches a raw
choice argument with the wrapperβs own check and message (#1740, #1741). The
Rust side sends a marker class that the wrappers file replaces with
.miniextendr_conversion_error_class; see
CONDITIONS.md.
πBest Practices
π1. Prefer Result Over Panic
// Good: Clear error handling
#[miniextendr]
pub fn parse_config(json: &str) -> Result<Config, String> {
serde_json::from_str(json)
.map_err(|e| format!("Invalid JSON: {}", e))
}
// Avoid: Panics hide the problem
#[miniextendr]
pub fn parse_config(json: &str) -> Config {
serde_json::from_str(json).unwrap() // Panic message is cryptic
}π2. Use Option for NA-Safe Operations
// Good: Explicit NA handling
#[miniextendr]
pub fn safe_divide(a: Option<f64>, b: Option<f64>) -> Option<f64> {
match (a, b) {
(Some(x), Some(y)) if y != 0.0 => Some(x / y),
_ => None, // Returns NA
}
}
// Avoid: Will panic on NA
#[miniextendr]
pub fn unsafe_divide(a: f64, b: f64) -> f64 {
a / b
}π3. Validate Early
#[miniextendr]
pub fn process_matrix(data: Vec<f64>, rows: i32, cols: i32) -> Vec<f64> {
// Validate at entry point
if rows <= 0 || cols <= 0 {
panic!("rows and cols must be positive");
}
let expected_len = (rows * cols) as usize;
if data.len() != expected_len {
panic!("data length {} doesn't match {}x{}", data.len(), rows, cols);
}
// Now process with confidence
compute(data, rows as usize, cols as usize)
}π4. Context in Error Messages
#[miniextendr]
pub fn load_model(path: &str) -> Result<Model, String> {
let bytes = std::fs::read(path)
.map_err(|e| format!("Failed to read '{}': {}", path, e))?;
Model::from_bytes(&bytes)
.map_err(|e| format!("Invalid model format in '{}': {}", path, e))
}π5. Donβt Swallow Errors
// Bad: Silent failure
#[miniextendr]
pub fn maybe_compute(x: i32) -> i32 {
risky_operation(x).unwrap_or(0) // Error silently becomes 0
}
// Good: Return Option to signal failure
#[miniextendr]
pub fn maybe_compute(x: i32) -> Option<i32> {
risky_operation(x).ok() // Caller sees NA on failure
}
// Also good: Explicit error
#[miniextendr]
pub fn compute(x: i32) -> Result<i32, String> {
risky_operation(x).map_err(|e| e.to_string())
}π6. Document Error Conditions
/// Compute factorial.
///
/// @param n Non-negative integer (max 20 to avoid overflow)
/// @return n! as integer
/// @examples
/// factorial(5L) # 120
/// factorial(-1L) # Error
#[miniextendr]
pub fn factorial(n: i32) -> Result<i64, String> {
if n < 0 {
return Err("n must be non-negative".into());
}
if n > 20 {
return Err("n > 20 would overflow i64".into());
}
Ok((1..=n as i64).product())
}
πError Recovery Patterns
πFallback Values
#[miniextendr]
pub fn safe_log(x: f64) -> f64 {
if x <= 0.0 {
f64::NEG_INFINITY // Mathematical convention
} else {
x.ln()
}
}πPartial Results
#[miniextendr]
pub fn parse_numbers(strings: Vec<String>) -> Vec<Option<f64>> {
strings.iter()
.map(|s| s.parse().ok()) // Failed parses become None (NA in R)
.collect()
}πCleanup on Error
#[miniextendr]
pub fn transactional_write(path: &str, data: &[u8]) -> Result<(), String> {
let temp_path = format!("{}.tmp", path);
// Write to temp file first
std::fs::write(&temp_path, data)
.map_err(|e| format!("Write failed: {}", e))?;
// Atomic rename - if this fails, temp file is left for debugging
std::fs::rename(&temp_path, path)
.map_err(|e| {
// Clean up temp file on rename failure
let _ = std::fs::remove_file(&temp_path);
format!("Rename failed: {}", e)
})
}
πDebugging Tips
πEnable Backtraces
See Panic Hook and Backtraces above for full details.
Sys.setenv(MINIEXTENDR_BACKTRACE = "1")
my_function_that_panics()πNested Panic Location
Panics preserve the original location:
fn inner_function() {
panic!("Inner error"); // This location is reported
}
fn middle_function() {
inner_function();
}
#[miniextendr]
pub fn outer_function() {
middle_function(); // Error shows inner_function location
}πPrint Debugging
Use Rβs console for visibility:
#[miniextendr]
pub fn debug_function(x: i32) -> i32 {
r_println!("Input: {}", x);
let result = complex_computation(x);
r_println!("Result: {}", result);
result
}
Or Rustβs stderr (visible in R console):
#[miniextendr]
pub fn debug_function(x: i32) -> i32 {
eprintln!("[Rust] Input: {}", x);
// ...
}
πCommon Pitfalls
π1. Panicking in Drop
Avoid panicking in destructors - it can cause double-panic abort:
// Bad
impl Drop for MyResource {
fn drop(&mut self) {
if self.is_invalid() {
panic!("Resource in bad state!"); // Don't do this
}
}
}
// Good
impl Drop for MyResource {
fn drop(&mut self) {
if self.is_invalid() {
eprintln!("Warning: dropping invalid resource");
}
}
}π2. Calling R from Wrong Thread
// Bad: Crashes or causes undefined behavior
std::thread::spawn(|| {
r_println!("Hello!"); // R API call from wrong thread
});
// Good: keep arbitrary spawned threads Rust-only
std::thread::spawn(|| {
eprintln!("Hello!"); // Rust-only output is safe
});
// For miniextendr's opt-in worker, route R work to the main thread:
#[miniextendr(worker)]
fn routed_output() {
with_r_thread(|| r_println!("Hello!"));
}π3. Ignoring Result
// Bad: Error silently ignored
#[miniextendr]
pub fn risky() {
let _ = fallible_operation(); // What if this failed?
}
// Good: Handle or propagate
#[miniextendr]
pub fn risky() -> Result<(), String> {
fallible_operation()?;
Ok(())
}
πKnown Limitations
- Spawned-thread panics cannot be cleanly propagated through
extern "C-unwind"boundaries. Convert thread errors toResultinstead of usingresume_unwind. See GAPS.md. - Thread safety debug assertions for direct SEXP access only run in debug
builds. Checked FFI wrappers route through
with_r_threadin all build modes. See GAPS.md.
See GAPS.md for the full catalog of known limitations.
πSee Also
- FEATURE_DEFAULTS.md β Project-wide feature defaults
- MINIEXTENDR_ATTRIBUTE.md β Complete
#[miniextendr]option reference - ENVIRONMENT_VARIABLES.md β
MINIEXTENDR_BACKTRACEand other env vars - THREADS.md β Worker thread architecture and thread safety
- SAFETY.md β Safety invariants (R_UnwindProtect, GC protection)
- TYPE_CONVERSIONS.md β Type conversion error messages
- MACRO_ERRORS.md β Proc-macro and lint error codes