Skip to content

Flatten Error type to canonical M-ERRORS shape (drop Client wrapper, Box<dyn> source, Option<ErrorKind>) #70

@StefanSteiner

Description

@StefanSteiner

Summary

The hyperdb-api Error type today is a thin wrapper around hyperdb_api_core::client::Error plus a few high-level variants, with Error::kind() -> Option<ErrorKind> (only the Client variant carries a kind). This shape conflicts with two rules from the Microsoft Pragmatic Rust Guidelines that the project commits to in CONTRIBUTING.md:42. Proposing to flatten the type to the canonical M-ERRORS shape.

Current state

hyperdb-api/src/error.rs:

#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
    #[error("{0}")]
    Client(#[from] hyperdb_api_core::client::Error),  // wraps another error enum
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Invalid name: {0}")]
    InvalidName(String),
    #[error("Invalid table definition: {0}")]
    InvalidTableDefinition(String),
    #[error("Not found: {0}")]
    NotFound(String),
    #[error("Already exists: {0}")]
    AlreadyExists(String),
    #[error("{message}")]
    #[non_exhaustive]
    Other {
        message: String,
        #[source]
        source: Option<Box<dyn StdError + Send + Sync>>,   // dyn escape hatch
    },
}

pub fn kind(&self) -> Option<ErrorKind> {
    match self {
        Error::Client(err) => Some(err.kind()),
        _ => None,                                          // most variants → None
    }
}

Original gap analysis: §10 of docs/RUST_API_GAP_ANALYSIS.md (predecessor repo) — "ErrorKind is re-exported from hyper-client but its own top-level kind() returns Option<ErrorKind> — makes match-on-error less clean than a single unified ErrorKind."

Microsoft guideline conflicts

CONTRIBUTING.md:42 commits the project to the Microsoft Pragmatic Rust Guidelines. Two rules apply directly:

M-ERRORS-CANONICAL-STRUCTS

Each crate exposes one canonical error type that callers can match on directly. The current shape forces callers to first match on Error::Client(_), then call .kind(), then match on ErrorKind — a two-level match for every database error. Returning Option<ErrorKind> is the API symptom of this two-level nesting.

M-ERRORS-AVOID-WRAPPING-AND-AS-DYN

Other { source: Option<Box<dyn StdError + Send + Sync>> } (error.rs:50) is precisely the Box<dyn StdError> escape hatch the rule discourages. It defeats Send/Sync/'static reasoning at the type level and turns inspection of the underlying cause into a dynamic downcast.

Proposed redesign

Flatten to a single enum whose variants directly express the failure modes — no nested client::Error, no Box<dyn> source.

#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
    // Connection / transport
    #[error("connection error: {0}")]
    Connection(String),

    #[error("authentication failed: {0}")]
    Authentication(String),

    #[error("TLS error: {0}")]
    Tls(String),

    // Protocol / server
    #[error("protocol error: {0}")]
    Protocol(String),

    #[error("server error{}: {message}", sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default())]
    Server { sqlstate: Option<String>, message: String },

    // I/O
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    // Catalog / validation (kept from current type)
    #[error("invalid name: {0}")]
    InvalidName(String),

    #[error("invalid table definition: {0}")]
    InvalidTableDefinition(String),

    #[error("not found: {0}")]
    NotFound(String),

    #[error("already exists: {0}")]
    AlreadyExists(String),
}

impl Error {
    pub fn sqlstate(&self) -> Option<&str> { /* from Server variant */ }
    // No more `kind()` method — callers match on the enum directly.
}

Concrete changes

  • Remove the Client(hyperdb_api_core::client::Error) variant. Map each client::ErrorKind to a direct top-level variant.
  • Remove the Other { message, source: Option<Box<dyn StdError>> } variant. Replace any internal use with a specific variant.
  • Remove Error::kind() -> Option<ErrorKind>. Callers match directly on the flattened enum.
  • Remove Error::new / Error::with_cause (they only exist to populate Other). If a generic-message escape hatch is genuinely needed, add one variant for it (e.g. Internal(String)) rather than reintroducing Box<dyn>.
  • Keep Error::sqlstate() as a convenience accessor on the Server variant.
  • Update every Error::new("…") / Error::with_cause call site in the workspace to use a specific variant.
  • Re-export of hyperdb_api_core::client::ErrorKind from hyperdb-api's public API: remove (callers no longer need it).

Backwards compatibility

Breaking change. Any caller that:

  • Pattern-matches on Error::Client(_), Error::Other { .. },
  • Calls Error::kind(),
  • Calls Error::new / Error::with_cause,
  • Imports hyperdb_api::ErrorKind,

will fail to compile. Migration is mechanical but case-by-case. Released as feat!: per Conventional Commits with a major version bump.

Migration recipe

Before:

match err.kind() {
    Some(ErrorKind::Connection) => retry(),
    Some(ErrorKind::Authentication) => prompt_creds(),
    _ => return Err(err),
}

After:

match err {
    Error::Connection(_) => retry(),
    Error::Authentication(_) => prompt_creds(),
    other => return Err(other),
}

Open questions

  • Should Server carry a structured Position field (line/column from the SQLSTATE diagnostic) instead of just the message? Cleaner, but expands the breaking-change surface.
  • Should Connection / Tls / Authentication carry a typed cause (e.g. #[source] std::io::Error) instead of String? More information, but requires unification with how hyperdb-api-core reports those internally.

Related

Metadata

Metadata

Assignees

Labels

breaking-changePublic API change that is not backwards-compatibleenhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions