Skip to main content

Error Handling

Two error types drive extension loading: LoaderError for dependency, schema, migration, and path failures, and ConfigError for configuration validation.

The extension system defines two primary error types: LoaderError for extension loading and ConfigError for configuration validation.

LoaderError

Errors during extension registration, validation, and initialization:

#[derive(Debug, Error)]
pub enum LoaderError {
    #[error("Extension '{extension}' requires dependency '{dependency}' which is not registered")]
    MissingDependency {
        extension: String,
        dependency: String,
    },

    #[error("Extension with ID '{0}' is already registered")]
    DuplicateExtension(String),

    #[error("Failed to initialize extension '{extension}': {message}")]
    InitializationFailed { extension: String, message: String },

    #[error("Failed to install schema for extension '{extension}': {message}")]
    SchemaInstallationFailed { extension: String, message: String },

    #[error("Migration failed for extension '{extension}': {message}")]
    MigrationFailed { extension: String, message: String },

    #[error(
        "Migration {version} for extension '{extension}' is not reversible (no down SQL provided)"
    )]
    MigrationNotReversible { extension: String, version: u32 },

    #[error("Configuration validation failed for extension '{extension}': {message}")]
    ConfigValidationFailed { extension: String, message: String },

    #[error("Extension '{extension}' uses reserved API path '{path}'")]
    ReservedPathCollision { extension: String, path: String },

    #[error("Extension '{extension}' has invalid base path '{path}': must start with /api/")]
    InvalidBasePath { extension: String, path: String },

    #[error("Dependency cycle detected while ordering extensions: {chain}")]
    DependencyCycle { chain: String },
}

Additional variants cover schema ownership and seed validation: CrossExtensionAlterUndeclared, DuplicateTableOwner, CrossExtensionTableNotOwned, InvalidSeedStatement, SeedInsertNotIdempotent, and SeedFailed.

Handling LoaderError

Extensions are registered through the typed ExtensionBuilder, whose build() returns Result<TypedExtensionRegistry, LoaderError>:

match ExtensionBuilder::new()
    .extension(UsersExtension)
    .extension(OauthExtension)
    .build()
{
    Ok(registry) => {
        // Proceed with startup
    }
    Err(LoaderError::MissingDependency { extension, dependency }) => {
        eprintln!("Extension '{}' requires '{}' - ensure it's registered first", extension, dependency);
        std::process::exit(1);
    }
    Err(LoaderError::DependencyCycle { chain }) => {
        eprintln!("Dependency cycle: {}", chain);
        eprintln!("Break the cycle by restructuring dependencies");
        std::process::exit(1);
    }
    Err(e) => {
        eprintln!("Extension error: {}", e);
        std::process::exit(1);
    }
}

ConfigError

Errors during configuration validation:

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("Configuration key '{0}' not found")]
    NotFound(String),

    #[error("Invalid configuration value for '{key}': {message}")]
    InvalidValue {
        key: String,
        message: String,
    },

    #[error("Failed to parse configuration: {message}")]
    ParseError { message: String },

    #[error("Schema validation failed: {0}")]
    SchemaValidation(String),
}

Creating ConfigError

fn validate_config(&self, config: &JsonValue) -> Result<(), ConfigError> {
    // Key not found
    let api_key = config.get("api_key")
        .ok_or_else(|| ConfigError::NotFound("api_key".to_string()))?;

    // Invalid value
    let max_items = config.get("max_items")
        .and_then(|v| v.as_i64())
        .ok_or_else(|| ConfigError::InvalidValue {
            key: "max_items".to_string(),
            message: "must be an integer".to_string(),
        })?;

    if max_items > 10000 {
        return Err(ConfigError::InvalidValue {
            key: "max_items".to_string(),
            message: "cannot exceed 10000".to_string(),
        });
    }

    // Parse error
    let url = config.get("api_url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| ConfigError::ParseError {
            message: "api_url must be a string".to_string(),
        })?;

    url::Url::parse(url).map_err(|e| ConfigError::ParseError {
        message: e.to_string(),
    })?;

    Ok(())
}

Extension Error Handling

Extensions should define their own error types:

use thiserror::Error;
use axum::http::StatusCode;
use systemprompt::prelude::ExtensionError;

#[derive(Error, Debug)]
pub enum MyExtensionError {
    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

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

    #[error("External service error: {0}")]
    External(String),
}

impl MyExtensionError {
    pub fn code(&self) -> &'static str {
        match self {
            Self::NotFound(_) => "NOT_FOUND",
            Self::Database(_) => "DATABASE_ERROR",
            Self::Validation(_) => "VALIDATION_ERROR",
            Self::External(_) => "EXTERNAL_ERROR",
        }
    }

    pub fn status(&self) -> StatusCode {
        match self {
            Self::NotFound(_) => StatusCode::NOT_FOUND,
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::Validation(_) => StatusCode::BAD_REQUEST,
            Self::External(_) => StatusCode::BAD_GATEWAY,
        }
    }

    pub fn is_retryable(&self) -> bool {
        matches!(self, Self::Database(_) | Self::External(_))
    }
}

Error Context

Add context to errors:

use anyhow::Context;

async fn load_extension(id: &str) -> Result<Arc<dyn Extension>, LoaderError> {
    let ext = registry.get(id)
        .ok_or_else(|| LoaderError::MissingDependency {
            extension: "current".to_string(),
            dependency: id.to_string(),
        })?;

    ext.validate()
        .context("validation failed")
        .map_err(|e| LoaderError::InitializationFailed {
            extension: id.to_string(),
            message: e.to_string(),
        })?;

    Ok(ext.clone())
}

Debugging

Enable Debug Logging

RUST_LOG=systemprompt=debug systemprompt infra services serve

Common Error Messages

MissingDependency:

Extension 'oauth' requires dependency 'users' which is not registered

→ Ensure the 'users' extension is registered in the ExtensionBuilder chain before its dependents

DependencyCycle:

Dependency cycle detected while ordering extensions: a -> b -> c -> a

→ Restructure dependencies to break the cycle

ReservedPathCollision:

Extension 'my-ext' uses reserved API path '/api/v1/users'

→ Change to a non-reserved path like /api/v1/my-ext

SchemaInstallationFailed:

Failed to install schema for extension 'users': relation "users" already exists

→ Use CREATE TABLE IF NOT EXISTS

ConfigValidationFailed:

Configuration validation failed for extension 'my-ext': api_url must use HTTPS

→ Fix the value in the extension's YAML config under services/