QoreDB LogoQoreDB
Back to blog
ArchitectureTechnical journal

Error handling in QoreDB: one unified type, sanitized at the boundary

QoreDB talks to sixteen database engines, and every one of them fails in its own way. sqlx returns its own errors, tiberius for SQL Server has its own, the mongodb driver yet another vocabulary. If those errors flowed straight up to the UI, every screen would need to know the…

Raphaël – Creator of QoreDBRaphaël – Creator of QoreDB
3 min read
Error handling in QoreDB: one unified type, sanitized at the boundary

QoreDB talks to sixteen database engines, and every one of them fails in its own way. sqlx returns its own errors, tiberius for SQL Server has its own, the mongodb driver yet another vocabulary. If those errors flowed straight up to the UI, every screen would need to know the failure modes of every library. QoreDB does the opposite: a single error type, a translation at the driver level, and a systematic scrub before anything leaves the process.

A single error type for every engine

The central type lives in src-tauri/crates/qore-core/src/error.rs. It is an EngineError enum that derives thiserror::Error for display, plus Serialize / Deserialize so it can cross the Rust-to-frontend boundary. Every family of failure — connection, authentication, syntax, execution, timeout, SSH tunnel, TLS — gets its own variant.

error.rs
#[derive(Debug, Error, Serialize, Deserialize)]
pub enum EngineError {
    #[error("Connection failed: {message}")]
    ConnectionFailed { message: String },

    #[error("Operation timed out after {timeout_ms}ms")]
    Timeout { timeout_ms: u64 },

    #[error("Too many concurrent queries ({current}/{limit})")]
    TooManyConcurrentQueries { current: u32, limit: u32 },

    #[error("Feature not supported: {message}")]
    NotSupported { message: String },
    // ...
}

The key detail is that the variants carry named fields, not plain strings. Timeout carries a timeout_ms: u64, TooManyConcurrentQueries keeps the current count and the limit, ResultTooLarge the row count and the ceiling. The information stays typed: the frontend can decide what to show without re-parsing free-form text.

Constructors instead of literals

Building a variant by hand is verbose. So the module exposes factory functions that accept anything convertible to a string through impl Into<String>. Call sites stay short, and the factory name documents the intent.

error.rs
pub fn execution_error(msg: impl Into<String>) -> Self {
    Self::ExecutionError { message: msg.into() }
}

pub fn too_many_queries(current: u32, limit: u32) -> Self {
    Self::TooManyConcurrentQueries { current, limit }
}

Each driver translates its native errors

The translation happens as close to the source as possible, inside the driver itself. The pattern is always the same: a .map_err(...) that wraps the native error into the right EngineError variant. A failed MongoDB connection becomes a ConnectionFailed, a rejected SQL query becomes an ExecutionError.

drivers
// drivers/mongodb.rs
let mut options = ClientOptions::parse(&conn_str)
    .await
    .map_err(|e| EngineError::connection_failed(e.to_string()))?;

// drivers/postgres.rs
let current_db: (String,) = sqlx::query_as("SELECT current_database()")
    .fetch_one(pool)
    .await
    .map_err(|e| EngineError::execution_error(e.to_string()))?;

This is consistent with QoreDB's stance: it does not emulate engines, but it does unify how they report failures. The rest of the application never sees an error type specific to sqlx or to the Mongo driver; it only ever sees an EngineError.

EngineResult and propagation with ?

To avoid repeating Result<T, EngineError> everywhere, the module defines the alias EngineResult<T>. Combined with the ? operator, it makes propagation implicit: every fallible call bubbles the error up a level with no boilerplate. A service layer wraps this type in ServiceError, with a From<EngineError> conversion so the bubbling stays automatic.

error.rs
pub type EngineResult<T> = Result<T, EngineError>;

// qore-service/src/error.rs
pub enum ServiceError {
    Engine(EngineError),
    Message(String),
}

impl From<EngineError> for ServiceError {
    fn from(e: EngineError) -> Self {
        ServiceError::Engine(e)
    }
}

Scrubbing the message before it leaves the process

A raw error message is a leak risk. A connection string can contain postgres://user:pass@host, an absolute path reveals the machine's layout. Before exposing a message, QoreDB runs it through sanitize_error_message, which applies a set of regular expressions compiled once via OnceLock. Credentials in URLs are masked, password= parameters replaced, absolute Unix and Windows paths reduced to a marker.

error.rs
// postgres://user:pass@host -> postgres://***@host
(Regex::new(r"(?i)((?:postgres|mysql|mongodb|redis|rediss)://)([^@]+)@").unwrap(),
 "${1}***@"),
(Regex::new(r"(?i)(password|passwd|pwd)\s*=\s*\S+").unwrap(),
 "${1}=***"),
// Absolute Unix or Windows paths.
(Regex::new(r"(/(?:Users|home|tmp|var|etc)/\S+|[A-Z]:\\[^\s:]+)").unwrap(),
 "[path]"),
Sanitization lives in qore-core, at the level of the error type itself, not in each screen. It is impossible to forget to scrub a message: the factory for frontend-bound text is sanitized_message(), and there is no other clean way out.

The Tauri boundary only lets scrubbed text through

Tauri commands, the only point of contact between the Rust backend and the UI, return already-sanitized strings. The pattern .map_err(|e| e.sanitized_message())? recurs everywhere, from resolving a saved connection to exporting a schema. The connection test, for instance, puts the scrubbed message into an error field of its response rather than failing the command outright.

commands/driver.rs
Err(e) => Ok(DriverInfoResponse {
    success: false,
    driver: None,
    error: Some(e.sanitized_message()),
}),

A stance that holds end to end

The path of an error through QoreDB is linear: the driver translates the native failure into a typed EngineError, propagation bubbles up via EngineResult and the ? operator, and the boundary with the frontend exposes only a scrubbed message. The same care that leads QoreDB to refuse to emulate features an engine lacks shows up here too: rather than let a sensitive detail slip out, it prefers a short, safe message. An error stays useful information — never an open door.

Newsletter

Stay updated on new releases

Subscribe to get product releases, new drivers notifications, and technical tutorials.

🎁 Bonus: Get our free SQL Performance Cheat Sheet — 9 pages, PostgreSQL / MySQL / SQLite (PDF)!
Share
Error handling in QoreDB: one unified type, sanitized at the boundary - Blog - QoreDB