node-app-sdk-rust 5.27.0

Rust SDK for building Node-App native plugins (cdylib) — Lightning-powered marketplace nodes
Documentation
//! Typed errors for the public api-store LLM endpoint (feature 472).
//!
//! Resolves UI finding U1: applications need a typed, pattern-matchable
//! error for the structured 400 body produced when `route: Private` cannot
//! be honoured on the selected executor. Mirrors the TypeScript surface in
//! `sdk/typescript/src/llm/errors.ts`.

use serde::{Deserialize, Serialize};

use super::types::LlmRoute;

/// Reason the peer rejected a `route: Private` request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivateUnavailableReason {
    NoEsp32Endpoint,
    FirmwareUpgradeRequired,
    PeerOffline,
    PlatformNotEnabled,
}

/// Typed error returned by the SDK when the host emits the structured 400
/// body for `api_store.private_unavailable`.
///
/// Inspect `fallback_available` to decide whether retrying with
/// [`LlmRoute::Auto`] would succeed. The L402 macaroon is NOT consumed by
/// this failure path (security invariant S6) — applications can retry with
/// the same token without re-paying.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[error("private routing unavailable: {reason:?} (fallback_available={fallback_available})")]
pub struct PrivateUnavailableError {
    pub reason: PrivateUnavailableReason,
    pub fallback_available: bool,
}

/// Typed error returned by the SDK when `route` + `execution_preference`
/// form a rejected combination per the Routing Truth Table in
/// `data-model.md §4`.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[error("invalid route_mode + execution_preference combination: route_mode={route_mode:?}, execution_preference={execution_preference:?} — {hint}")]
pub struct InvalidRouteModeCombinationError {
    pub route_mode: LlmRoute,
    pub execution_preference: Option<String>,
    pub hint: String,
}

/// Wire shape of the structured 400 body for `api_store.private_unavailable`.
#[derive(Debug, Clone, Deserialize)]
pub struct PrivateUnavailableBody {
    pub code: String,
    pub requested_route: String,
    pub reason: PrivateUnavailableReason,
    pub fallback_available: bool,
}

impl From<PrivateUnavailableBody> for PrivateUnavailableError {
    fn from(body: PrivateUnavailableBody) -> Self {
        Self {
            reason: body.reason,
            fallback_available: body.fallback_available,
        }
    }
}

/// Wire shape of the structured 400 body for
/// `api_store.invalid_route_mode_combination`.
#[derive(Debug, Clone, Deserialize)]
pub struct InvalidRouteModeCombinationBody {
    pub code: String,
    pub route_mode: LlmRoute,
    #[serde(default)]
    pub execution_preference: Option<String>,
    pub hint: String,
}

impl From<InvalidRouteModeCombinationBody> for InvalidRouteModeCombinationError {
    fn from(body: InvalidRouteModeCombinationBody) -> Self {
        Self {
            route_mode: body.route_mode,
            execution_preference: body.execution_preference,
            hint: body.hint,
        }
    }
}

/// Decode a 400 response body into the corresponding typed error variant,
/// returning `None` for unknown codes so callers can fall through to a
/// generic transport error.
pub fn map_api_error_body(bytes: &[u8]) -> Option<Box<dyn std::error::Error + Send + Sync>> {
    let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
    let code = v.get("code")?.as_str()?;
    match code {
        "api_store.private_unavailable" => {
            let body: PrivateUnavailableBody = serde_json::from_value(v).ok()?;
            Some(Box::new(PrivateUnavailableError::from(body)))
        }
        "api_store.invalid_route_mode_combination" => {
            let body: InvalidRouteModeCombinationBody = serde_json::from_value(v).ok()?;
            Some(Box::new(InvalidRouteModeCombinationError::from(body)))
        }
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decodes_private_unavailable() {
        let body = br#"{
            "code": "api_store.private_unavailable",
            "requested_route": "private",
            "reason": "no_esp32_endpoint",
            "fallback_available": true
        }"#;
        let err = map_api_error_body(body).expect("typed err");
        let downcast = err
            .downcast_ref::<PrivateUnavailableError>()
            .expect("PrivateUnavailableError");
        assert_eq!(downcast.reason, PrivateUnavailableReason::NoEsp32Endpoint);
        assert!(downcast.fallback_available);
    }

    #[test]
    fn unknown_code_returns_none() {
        assert!(map_api_error_body(br#"{ "code": "api_store.other" }"#).is_none());
    }

    #[test]
    fn invalid_route_mode_combination_decodes() {
        let body = br#"{
            "code": "api_store.invalid_route_mode_combination",
            "route_mode": "private",
            "execution_preference": "network_only",
            "hint": "fix it"
        }"#;
        let err = map_api_error_body(body).expect("typed err");
        let downcast = err
            .downcast_ref::<InvalidRouteModeCombinationError>()
            .expect("InvalidRouteModeCombinationError");
        assert_eq!(downcast.route_mode, LlmRoute::Private);
        assert_eq!(downcast.execution_preference.as_deref(), Some("network_only"));
    }
}