Drive Errors

The previous two chapters covered consensus errors -- the carefully serialized, code-stable errors that get sent across the network. Drive errors are a different beast entirely. They are internal errors that arise from the storage layer, the database, and the logic that sits between state transitions and GroveDB. They are not serialized as consensus errors, and they do not need stable numeric codes. The one place they reach a client is a query's gRPC status, described in Query errors on the wire.

But they do need to be well-organized, because Drive is where most of the platform's complexity lives. When something goes wrong in Drive, you need to know immediately whether it is a corrupted database, a protocol-level validation failure, a fee calculation error, or a bug in your own code.

Query errors on the wire

A gRPC query answers a failed request with a status code, and clients read that code to decide whose fault the failure is. rs-dapi-client takes INVALID_ARGUMENT as the caller's mistake: the error goes back to the caller, and nothing else happens. It normally takes UNKNOWN and INTERNAL as a fault of the node: the client stops using that node for a while (a ban) and sends the request to the next one. Known older-node request-refusal messages are exceptions: the client does not retry statuses containing storage: query: , drive error: query: or proved path queries can not be for limit 0. A request error answered as INTERNAL therefore gets every node banned in turn, since every node refuses the request the same way.

The mapping lives in packages/rs-drive-abci/src/query/service.rs:

ErrorStatus
QueryError::InvalidArgument, QueryError::TooManyElementsINVALID_ARGUMENT
A QuerySyntaxError, whether the handler reports it (QueryError::Query, QueryError::Drive(Error::Query)) or returns it (Error::Drive(Error::Query))INVALID_ARGUMENT
QueryError::NotFoundNOT_FOUND
QueryError::ResourceExhaustedRESOURCE_EXHAUSTED
A VersionNotActive, whether the handler reports it (QueryError::Drive(Error::Drive(DriveError::VersionNotActive))) or returns it (Error::Drive(Error::Drive(DriveError::VersionNotActive)), Error::Execution(ExecutionError::VersionNotActive))INVALID_ARGUMENT; the current protocol version does not have the feature yet, such as identity key budgets before protocol version 14, and every node at the same height runs that version whatever its build
A decoding error, an unsupported request version, an UnknownVersionMismatch the handler reports, or another QueryError not listed aboveUNKNOWN; undecodable or newer requests may succeed on another node, and a version this build does not know is a fault of the build
Any other Err a handler returnsINTERNAL

A long status message retains at most a 1024-byte UTF-8 prefix, followed by ... (N bytes truncated). The complete message is therefore longer than 1024 bytes. Tonic percent-encodes it in the grpc-message HTTP/2 header; bounding the prefix prevents an echoed request field from exceeding the peer's header-list limit. Otherwise the client may see a retryable transport error instead of the original validation status.

A handler checks the request before Drive sees it, and refuses what no node can answer with a validation error, never with ?. Two rules decide what it refuses:

  • Preserve usable answers. Empty unproved selections and documented defaults remain supported. Some unusable proved selections and out-of-range inputs require refusal, even if an older node returned a response.
  • GroveDB cannot prove an empty query, or one with a limit of 0. When a proof is asked, an empty id list or a limit of 0 is refused; without a proof, the answer is empty.
RequestWithout a proofWith a proof
Empty id list: data contracts, identities balances, identities contract keys (ids or purposes), evonode blocks by ids, identity and identities token balances and infos, token statuses, addresses infosEmpty answerINVALID_ARGUMENT
Identity keys with limit 0, epochs info with count 0Empty answerINVALID_ARGUMENT
Limit or count 0 on contract history (or above 10), protocol upgrade vote status, evonode blocks by range, identity votes, pre-programmed distributions, group infos, group actionsINVALID_ARGUMENTINVALID_ARGUMENT
Identity keys search without a limitINVALID_ARGUMENTServed
Specific identity keys with a limit below their number of idsThe first keys that exist, in encoded-key orderThe same keys, proved
Documents v0 with limit 0, shielded encrypted notes with count 00 means the default0 means the default

An empty identities balances request with a proof changes from OK with an unusable proof to INVALID_ARGUMENT. A proof that fails to verify is treated as a node fault too, so refusing that request matters as much as the status codes. Address branch depths are checked before narrowing the wire u32: a value such as 263 is refused rather than wrapping to the previously accepted depth 7. Status text and validation priority can also change.

These are query API changes, shared by the protocol versions selecting the affected query generations. They do not change block execution, persisted state or proof formats. The composite by-ID page validation also serves protocol versions 12 and 13; older schemas support value-bounded lookup joins, but predate the property-reference declarations needed for by-ID joins. At those versions, capped composite queries that require per-instance query limits are refused with INVALID_ARGUMENT. Drive checks executed capped composite queries against the protocol version's GroveDB capability before reading or proving them; other storage failures remain INTERNAL. An empty page that derives no capped child query remains supported.

Non-zero offsets on proved identity-key queries (except SpecificKeys, which ignores offsets), contract-history, document-history and identity-vote queries are refused with INVALID_ARGUMENT. An unproved key search also refuses non-zero offsets; an explicit zero offset behaves like omission. Supported raw pagination and ignored SpecificKeys offsets remain unchanged. Structural validation precedes these checks, which run before storage lookups: a valid proved document-history request with a non-zero offset returns the offset error even when its contract or document type is missing, while the corresponding raw request still returns NOT_FOUND.

SearchKey selection and CurrentKey alias limitations remain: its conditional branches lack query items, and identity key storage does not populate the empty CurrentKey alias. At protocol versions 12 and 13, an unproved AllKeysOfKind search with no offset can still return INTERNAL for unbounded terminal ranges; a CurrentKey search with more purpose/level pairs than its limit can exceed the terminal-key budget. SDK identity-vote offset paging remains unsupported and now receives INVALID_ARGUMENT rather than causing node bans.

Nodes released before these rules answer the same requests with UNKNOWN, INTERNAL, or that unusable proof. Client versions that reject unusable proved selections before choosing a node can protect callers of older nodes; server classification also covers requests that reach the wire. These server checks do not add SDK preflight validation or offset-proof pagination.

The Drive Error enum

The top-level error type lives in packages/rs-drive/src/error/mod.rs:

#![allow(unused)]
fn main() {
/// Errors
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Query error
    #[error("query: {0}")]
    Query(#[from] QuerySyntaxError),
    /// Storage Flags error
    #[error("storage flags: {0}")]
    StorageFlags(#[from] StorageFlagsError),
    /// Drive error
    #[error("drive: {0}")]
    Drive(#[from] DriveError),
    /// Proof error
    #[error("proof: {0}")]
    Proof(#[from] ProofError),
    /// GroveDB error
    #[error("grovedb: {0}")]
    GroveDB(Box<grovedb::Error>),
    /// Protocol error
    #[error("protocol: {0}")]
    Protocol(Box<ProtocolError>),
    /// Identity error
    #[error("identity: {0}")]
    Identity(#[from] IdentityError),
    /// Fee error
    #[error("fee: {0}")]
    Fee(#[from] FeeError),
    /// Document error
    #[error("document: {0}")]
    Document(#[from] DocumentError),
    /// Value error
    #[error("value: {0}")]
    Value(#[from] ValueError),
    /// DataContract error
    #[error("contract: {0}")]
    DataContract(#[from] DataContractError),
    /// Cache error
    #[error("contract: {0}")]
    Cache(#[from] CacheError),
    /// Protocol error with info string
    #[error("protocol: {0} ({1})")]
    ProtocolWithInfoString(Box<ProtocolError>, String),
    /// IO error with info string
    #[error("io: {0} ({1})")]
    IOErrorWithInfoString(Box<io::Error>, String),
}
}

This enum is a classic Rust error aggregator. It collects errors from every subsystem that Drive interacts with -- the query parser, the storage flags system, GroveDB, the protocol layer, identities, fees, documents, data contracts, and the cache. Each variant wraps a specific error type from that subsystem.

Notice the organizational difference from ConsensusError:

  • ConsensusError is organized by validation phase (basic, state, signature, fee)
  • Drive's Error is organized by subsystem (query, storage, grovedb, protocol, identity, etc.)

This makes sense. Consensus errors are about "what rule was violated." Drive errors are about "what component failed."

Box<ProtocolError> -- avoiding large enum variants

Two variants wrap their inner error in a Box:

#![allow(unused)]
fn main() {
/// GroveDB error
#[error("grovedb: {0}")]
GroveDB(Box<grovedb::Error>),

/// Protocol error
#[error("protocol: {0}")]
Protocol(Box<ProtocolError>),
}

Why? Because ProtocolError and grovedb::Error are large types. Without the Box, the entire Error enum would be as large as its largest variant, which could be hundreds of bytes. Since most Drive operations return Result<T, Error>, you would be paying that size cost on every Ok path too -- the Result itself is as large as the larger of T and Error.

Boxing the large variants means the Error enum stores only an 8-byte pointer for those cases, keeping the overall size reasonable. This is a common Rust pattern, and Clippy will warn you about it via the clippy::large_enum_variant lint.

The From trait chain

The #[from] attribute on most variants is a thiserror feature that automatically generates From implementations. For example, #[from] DriveError generates:

#![allow(unused)]
fn main() {
impl From<DriveError> for Error {
    fn from(value: DriveError) -> Self {
        Self::Drive(value)
    }
}
}

But look at the manually written From implementations at the bottom of the file:

#![allow(unused)]
fn main() {
impl From<ProtocolError> for Error {
    fn from(value: ProtocolError) -> Self {
        Self::Protocol(Box::new(value))
    }
}

impl From<grovedb::Error> for Error {
    fn from(value: grovedb::Error) -> Self {
        Self::GroveDB(Box::new(value))
    }
}

impl From<grovedb::element::error::ElementError> for Error {
    fn from(value: grovedb::element::error::ElementError) -> Self {
        Self::GroveDB(Box::new(grovedb::Error::ElementError(value)))
    }
}

impl From<ProtocolDataContractError> for Error {
    fn from(value: ProtocolDataContractError) -> Self {
        Self::Protocol(Box::new(ProtocolError::DataContractError(value)))
    }
}
}

These cannot use #[from] because they involve boxing or wrapping through an intermediate type. The ProtocolError conversion boxes the value. The grovedb::element::error::ElementError conversion wraps the error inside grovedb::Error::ElementError first, then boxes it. The ProtocolDataContractError conversion wraps through ProtocolError::DataContractError and then boxes.

These manual From implementations create multi-hop error conversion chains. When a function deep in Drive's GroveDB interaction layer returns a grovedb::element::error::ElementError, the ? operator can propagate it all the way up to a drive::Error in a single step, with the conversion chain handling the wrapping automatically.

The DriveError enum -- internal errors

The DriveError enum in packages/rs-drive/src/error/drive.rs is where Drive reports its own internal problems:

#![allow(unused)]
fn main() {
/// Drive errors
#[derive(Debug, thiserror::Error)]
pub enum DriveError {
    /// This error should never occur, it is the equivalent of a panic.
    #[error("corrupted code execution error: {0}")]
    CorruptedCodeExecution(&'static str),

    /// Platform expected some specific versions
    #[error("drive unknown version on {method}, received: {received}")]
    UnknownVersionMismatch {
        method: String,
        known_versions: Vec<FeatureVersion>,
        received: FeatureVersion,
    },

    /// A critical corrupted state should stall the chain.
    #[error("critical corrupted state error: {0}")]
    CriticalCorruptedState(&'static str),

    /// Error
    #[error("not supported error: {0}")]
    NotSupported(&'static str),

    // ... many more variants
}
}

Notice how DriveError uses &'static str for most of its error messages rather than String. This is deliberate -- these are internal error messages that should be known at compile time. A CorruptedCodeExecution("document tree missing expected root") is a fixed message that describes a specific bug or data corruption scenario. Using &'static str makes it clear that these are not user-facing messages and avoids allocations on error paths.

There are a few variants that do use String -- these are cases where the error message needs to include runtime data:

#![allow(unused)]
fn main() {
#[error("corrupted contract indexes error: {0}")]
CorruptedContractIndexes(String),

#[error("corrupted drive state error: {0}")]
CorruptedDriveState(String),
}

The severity spectrum

DriveError variants implicitly encode severity through naming conventions:

  • Corrupted* variants indicate data corruption. The database is in an unexpected state. These are serious problems that may indicate bugs or hardware failures:

    #![allow(unused)]
    fn main() {
    CorruptedCodeExecution(&'static str),
    CriticalCorruptedState(&'static str),
    CorruptedContractPath(&'static str),
    CorruptedDocumentPath(&'static str),
    CorruptedBalancePath(&'static str),
    CorruptedSerialization(String),
    CorruptedElementType(&'static str),
    CorruptedDriveState(String),
    }
  • Invalid* and NotSupported variants indicate logic errors -- the code is trying to do something that should not be possible:

    #![allow(unused)]
    fn main() {
    InvalidDeletionOfDocumentThatKeepsHistory(&'static str),
    InvalidContractHistoryFetchLimit(u16),
    NotSupported(&'static str),
    }
  • *NotFound and *DoesNotExist variants indicate missing data that was expected:

    #![allow(unused)]
    fn main() {
    DataContractNotFound(String),
    ElementNotFound(&'static str),
    PrefundedSpecializedBalanceDoesNotExist(String),
    }
  • Version mismatch variants indicate protocol version problems:

    #![allow(unused)]
    fn main() {
    UnknownVersionMismatch { method, known_versions, received },
    VersionNotActive { method, known_versions },
    }

When to use consensus errors vs drive errors

This is the key design decision you face when adding error handling to Drive code:

Use a consensus error when:

  • The error is caused by invalid user input (a malformed state transition)
  • The error needs to be communicated back to the client
  • The error needs a stable numeric code
  • Other nodes must produce the same error for the same input

Use a drive error when:

  • The error is caused by internal state (database corruption, missing paths)
  • The error indicates a bug in the platform code
  • The error is about version mismatches or unsupported features
  • The error involves the storage layer (GroveDB problems)

In practice, Drive functions often work with both. A typical pattern is a function that validates input (producing consensus errors) and then performs storage operations (which might produce drive errors). The return type is usually Result<T, Error> where Error is the Drive error, and consensus errors are embedded inside ProtocolError which is itself a variant of the Drive error.

The ? propagation pattern

Here is how error propagation typically works in Drive code:

#![allow(unused)]
fn main() {
fn apply_document_create(
    &self,
    document: &Document,
    contract: &DataContract,
    // ...
) -> Result<(), Error> {
    // This might return a grovedb::Error, which auto-converts via From
    let existing = self.grove_get(path, key, transaction)?;

    // This might return a DriveError
    if existing.is_some() {
        return Err(DriveError::CorruptedDocumentAlreadyExists(
            "document should not exist at this point"
        ).into());
    }

    // This might return a ProtocolError, which auto-converts via From + Box
    let serialized = document.serialize(platform_version)?;

    // This might return a grovedb::Error
    self.grove_insert(path, key, element, transaction)?;

    Ok(())
}
}

The ? operator handles all the conversions transparently. A grovedb::Error becomes Error::GroveDB(Box::new(...)). A DriveError becomes Error::Drive(...). A ProtocolError becomes Error::Protocol(Box::new(...)). The developer does not need to think about which From implementation is being invoked -- the type system figures it out.

Sub-module error types

Drive organizes its errors into submodules, each with its own error enum:

#![allow(unused)]
fn main() {
pub mod cache;      // CacheError
pub mod contract;   // DataContractError (Drive's own, distinct from DPP's)
pub mod document;   // DocumentError
pub mod drive;      // DriveError
pub mod fee;        // FeeError (Drive's own, distinct from consensus FeeError)
pub mod identity;   // IdentityError
pub mod proof;      // ProofError
pub mod query;      // QuerySyntaxError
}

Each of these has a #[from] conversion to the top-level Error, creating a clean hierarchy where subsystem-specific code can work with its own error type and callers can propagate through ? to the unified type.

Rules

Do:

  • Use Box for large error types (ProtocolError, grovedb::Error) to keep the enum small
  • Use &'static str for internal error messages that are known at compile time
  • Use String only when the message needs runtime data
  • Let #[from] generate From implementations where possible
  • Write manual From implementations when boxing or intermediate wrapping is needed
  • Follow the naming conventions: Corrupted* for data corruption, Invalid* for logic errors, *NotFound for missing data

Do not:

  • Use consensus errors for internal Drive problems
  • Use drive errors for user-facing validation failures
  • Forget to add a From implementation when introducing a new error type
  • Return a String error message when a typed error variant would be more informative
  • Panic in Drive code -- return a CorruptedCodeExecution or CriticalCorruptedState error instead