Report errors well
Every trait method returns Result<T, RpcError>, and which constructor you reach for decides what
the person running the query sees. There is no separate error hierarchy to learn — RpcError has a
type string, and the constructors name the sensible ones.
The constructors
Section titled “The constructors”| Constructor | Use it when |
|---|---|
RpcError::value_error | An argument or input value is wrong — out of range, wrong shape, unparseable. The one you will use most. |
RpcError::type_error | A column or value is the wrong type for what the function declared. |
RpcError::runtime_error | Something failed while doing the work: an upstream call, an Arrow build, an I/O error. |
RpcError::permission_error | The caller is not allowed to do this. |
RpcError::protocol_error / version_error | The peer sent something the protocol does not allow, or a version this worker cannot serve. Mostly raised by the framework. |
RpcError::new(ty, msg) | Any other type string, when you need one the helpers do not cover. |
The natural Rust move — map_err at the boundary — is also the right one:
let cast = arrow_cast::cast(col, &DataType::Int64)
.map_err(|e| RpcError::value_error(format!("`value` must be an integer: {e}")))?;
The error crosses the wire and is re-raised by the extension, so what the user sees is
Invalid Input Error: VGI Worker Exception: <your message> with the worker command appended. Write
the message for that reader: name the argument, say what was expected, and don’t assume they know a
worker exists.
Constraints you get for free
Section titled “Constraints you get for free”Before writing a validation by hand, check whether the argument spec already covers it. Declared constraints are validated by the framework at bind, before your code runs:
ArgSpec::const_arg("count", 0, "int64", "How many numbers to generate").with_ge(0.0)
Invalid Input Error: VGI Worker Exception: argument count: must be >= 0
with_ge, with_le, with_gt, with_lt, choices and a regex pattern are all available, and they
double as discovery metadata — vgi_function_arguments() reports them, so an agent inspecting the
catalog learns the valid range without calling anything.
A panic takes the worker with it
Section titled “A panic takes the worker with it”There is no panic recovery around your function. A panic!, an unwrap() on None, an
out-of-bounds index or an arithmetic overflow in debug aborts the process, and DuckDB reports a
broken pipe rather than anything about your function. The whole query fails, and so does every other
query using that worker.
That makes the ordinary Rust discipline load-bearing here:
// Kills the worker if the column is missing.
let col = batch.column_by_name("value").unwrap();
// Fails the query with a message naming the problem.
let col = batch
.column_by_name("value")
.ok_or_else(|| RpcError::value_error("expected a `value` column"))?;
as_primitive::<Int64Type>() is in the same family — it panics on the wrong type. It is safe when
the argument spec declared that type, because the extension will not route anything else; it is not
safe on a column whose type came from the caller’s relation. Use arrow_cast::cast there, which
returns an error you can map.
Two log channels
Section titled “Two log channels”eprintln!/ thelogcrate → the worker’s stderr. For operators.out.client_log(level, msg)→ back to the DuckDB client in-band, surfacing induckdb_logs()withtype='VGI'. For the person running the query.
Use the in-band channel for something the user can act on — a row skipped, a credential about to expire — and stderr for everything else.
On the stdio transport, stdout is the protocol. A stray println! injects bytes into the Arrow
IPC stream, and the failure is a decode error that looks nothing like a stray print. eprintln! is
the one you want.
Next steps
Section titled “Next steps”- The error type in full → Protocol & Arrow.
- Declaring constraints → Arguments.