klieo-a2a 3.4.0

Durable A2A v1.0 protocol layer atop klieo-bus traits.
Documentation
//! A2A error type with JSON-RPC code mapping.

use crate::envelope::{codes, JsonRpcError, JsonRpcResponse};
use thiserror::Error;

/// Errors returned by the A2A server / handler.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum A2aError {
    /// JSON-RPC method name not recognised. Maps to JSON-RPC -32601.
    #[error("method not found: {0}")]
    MethodNotFound(String),
    /// Params payload could not be decoded into the method's param type.
    /// Maps to JSON-RPC -32602.
    #[error("invalid params: {0}")]
    InvalidParams(String),
    /// JSON parse failure. Maps to JSON-RPC -32700.
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    /// Bus-layer failure (subscribe / publish / KV).
    #[error("bus error: {0}")]
    Bus(#[from] klieo_core::error::BusError),
    /// Task id not present in the store.
    #[error("task not found: {0}")]
    TaskNotFound(String),
    /// Catch-all server-side failure. Maps to JSON-RPC -32000.
    #[error("server error: {0}")]
    Server(String),
    /// The tenant-ownership store was unreachable, or returned an
    /// inconclusive verdict, while gating a request under strict tenant
    /// binding — the request is denied fail-closed rather than served
    /// unprotected. Maps to JSON-RPC -32000; Display is redaction-safe
    /// (carries no caller/principal/task detail on the wire — that lives
    /// in the server-side log emitted before this is returned).
    #[error("ownership store unavailable")]
    OwnershipUnavailable,
    /// Authentication or authorisation failure. Maps to A2A-specific -32001.
    #[error("unauthorized: {0}")]
    Unauthorized(String),
    /// The server was started with an invalid or dangerous configuration
    /// (e.g. non-loopback bind with an anonymous authenticator without the
    /// explicit `allow_public_bind` opt-in). Maps to JSON-RPC -32000.
    #[error("misconfigured: {0}")]
    Misconfigured(String),
    /// Resume requested with a cursor older than the oldest retained
    /// event in the buffer. Maps to JSON-RPC application code
    /// [`crate::envelope::codes::RESUME_BUFFER_EXPIRED`] (-32010).
    #[error("resume window expired (since_id={since_id})")]
    ResumeBufferExpired {
        /// Cursor the caller supplied.
        since_id: u64,
    },
    /// Server-side internal failure that preserves the underlying
    /// typed cause via `Error::source`. Distinct from
    /// [`A2aError::Server`] (bare string) because the source chain
    /// matters for tracing.
    ///
    /// Maps to JSON-RPC SERVER_ERROR (`-32000`). Display is the
    /// stable, redaction-safe string `"internal error"` — the inner
    /// cause is reachable only via `Error::source()` so callers that
    /// log server-side see the chain while wire envelopes carry no
    /// internal detail. Use for server-internal serialization,
    /// encode, or IPC failures that should NOT be mis-classified as
    /// client-input parse errors.
    #[error("internal error")]
    Internal {
        /// Underlying typed cause, preserved for `Error::source()`.
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },
    /// The remote A2A peer answered with a JSON-RPC error envelope.
    /// Raised client-side by [`crate::client::A2aClient`] when a reply
    /// carries `error` instead of `result`; the `code`/`message` are the
    /// peer's verbatim JSON-RPC error payload so the caller can branch on
    /// the wire code (e.g. -32601 method-not-found, -32000 server error).
    #[error("remote rpc error {code}: {message}")]
    Rpc {
        /// The peer's verbatim JSON-RPC error code — branch on it for the
        /// wire-level cause (e.g. -32601 method-not-found, -32000 server).
        code: i32,
        /// The peer's verbatim error message; treat as untrusted display
        /// text (it originated on the remote side).
        message: String,
    },
    /// No reply arrived on the client's inbox before the per-call
    /// deadline elapsed. Raised client-side by
    /// [`crate::client::A2aClient`]; the bus accepted the publish but the
    /// peer never answered (crashed, slow, or wrong agent id).
    #[error("rpc timed out after {0:?}")]
    Timeout(std::time::Duration),
    /// Multi-replica orphan: the streaming leader for this task
    /// died (TTL-expired KV claim) or never claimed leadership.
    /// Maps to JSON-RPC application code
    /// [`crate::envelope::codes::LEADER_DIED`] (`-32099`). The
    /// dispatcher writes a terminal frame to the resume buffer
    /// before raising so future resume clients see clean
    /// termination + can retry. ADR-020.
    #[error("stream leader died")]
    LeaderDied {
        /// Leader-registry stream id (e.g. `a2a.t-1`) so the
        /// client can correlate with bus telemetry.
        stream_id: String,
    },
}

impl A2aError {
    /// Map this error onto a [`JsonRpcResponse`] carrying the matching
    /// JSON-RPC error code.
    pub fn to_json_rpc_error(&self, request_id: serde_json::Value) -> JsonRpcResponse {
        let (code, message) = match self {
            Self::MethodNotFound(_) => (codes::METHOD_NOT_FOUND, self.to_string()),
            Self::InvalidParams(_) => (codes::INVALID_PARAMS, self.to_string()),
            Self::Json(_) => (codes::PARSE_ERROR, self.to_string()),
            Self::Bus(_)
            | Self::TaskNotFound(_)
            | Self::Server(_)
            | Self::OwnershipUnavailable
            | Self::Misconfigured(_)
            | Self::Internal { .. } => (codes::SERVER_ERROR, self.to_string()),
            Self::Unauthorized(_) => (codes::UNAUTHENTICATED, self.to_string()),
            // Client-side variants are never re-serialised onto the wire by
            // a server, but the match must stay exhaustive: re-emit the
            // peer's original code for `Rpc`, generic server error for a
            // local timeout.
            Self::Rpc { code, message } => (*code, message.clone()),
            Self::Timeout(_) => (codes::SERVER_ERROR, self.to_string()),
            Self::ResumeBufferExpired { .. } => (codes::RESUME_BUFFER_EXPIRED, self.to_string()),
            Self::LeaderDied { .. } => (codes::LEADER_DIED, self.to_string()),
        };
        let data = match self {
            Self::LeaderDied { stream_id } => Some(serde_json::json!({
                "stream_id": stream_id,
            })),
            _ => None,
        };
        JsonRpcResponse {
            jsonrpc: "2.0".to_string(),
            id: request_id,
            result: None,
            error: Some(JsonRpcError {
                code,
                message,
                data,
            }),
        }
    }
}

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

    #[test]
    fn method_not_found_maps_to_minus_32601() {
        let resp = A2aError::MethodNotFound("Foo".into()).to_json_rpc_error(json!(7));
        assert_eq!(resp.error.unwrap().code, -32601);
    }

    #[test]
    fn invalid_params_maps_to_minus_32602() {
        let resp = A2aError::InvalidParams("missing field".into()).to_json_rpc_error(json!("a"));
        assert_eq!(resp.error.unwrap().code, -32602);
    }

    #[test]
    fn json_error_maps_to_minus_32700() {
        let json_err = serde_json::from_str::<u32>("not json").unwrap_err();
        let resp = A2aError::from(json_err).to_json_rpc_error(json!(null));
        assert_eq!(resp.error.unwrap().code, -32700);
    }

    #[test]
    fn unauthorized_maps_to_minus_32001() {
        let resp = A2aError::Unauthorized("no token".into()).to_json_rpc_error(json!(1));
        assert_eq!(resp.error.unwrap().code, -32001);
    }

    #[test]
    fn resume_buffer_expired_renders_message() {
        let e = A2aError::ResumeBufferExpired { since_id: 42 };
        assert_eq!(e.to_string(), "resume window expired (since_id=42)");
    }

    #[test]
    fn resume_buffer_expired_maps_to_minus_32010() {
        let resp = A2aError::ResumeBufferExpired { since_id: 99 }.to_json_rpc_error(json!(1));
        assert_eq!(resp.error.unwrap().code, -32010);
    }

    #[test]
    fn internal_preserves_source_and_maps_to_server_error() {
        use std::error::Error as _;
        let inner = std::io::Error::other("synthetic encode failure");
        let err = A2aError::Internal {
            source: Box::new(inner),
        };
        assert!(err.source().is_some(), "Internal must preserve source");
        assert_eq!(
            err.to_string(),
            "internal error",
            "Display must be redaction-safe stable string, not surface inner Display",
        );
        let resp = err.to_json_rpc_error(json!(1));
        assert_eq!(resp.error.unwrap().code, codes::SERVER_ERROR);
    }

    #[test]
    fn misconfigured_maps_to_server_error() {
        let err = A2aError::Misconfigured("startup config rejected".into());
        let resp = err.to_json_rpc_error(json!(1));
        let inner = resp.error.expect("error envelope");
        assert_eq!(inner.code, codes::SERVER_ERROR);
        assert!(
            inner
                .message
                .to_lowercase()
                .contains("startup config rejected")
                || inner.message.to_lowercase().contains("misconfigured"),
            "expected message to surface details; got: {}",
            inner.message
        );
    }
}

/// Errors returned by [`crate::server::A2aDispatcherBuilder::build`] and
/// [`crate::server::A2aDispatcherBuilder::build_arc`].
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum A2aBuilderError {
    /// `with_cancel_subscription` was set but `build()` (not `build_arc()`) was called.
    #[error("with_cancel_subscription requires build_arc()")]
    CancelRequiresArc,
    /// `handler(...)` was not called on the builder.
    #[error("handler required; call .handler() before build")]
    MissingHandler,
    /// `authenticator(...)` was not called on the builder.
    #[error("authenticator required; call .authenticator() before build")]
    MissingAuthenticator,
    /// `pubsub(...)` or `with_in_process_pubsub()` was not called on the builder.
    #[error("pubsub required; call .pubsub() or .with_in_process_pubsub() before build")]
    MissingPubsub,
    /// `kv(...)` was not called on [`crate::http::A2aHttpServerBuilder`].
    /// The HTTP server's task store is backed by a [`klieo_core::KvStore`];
    /// without one there is nowhere to persist tasks for `SubscribeToTask`
    /// current-state replay.
    #[error("kv required; call .kv() before build")]
    MissingKv,
    /// The builder's `profile(..)` requirements were not met (e.g. regulated
    /// profile without tenant binding or with an anonymous authenticator).
    #[error(transparent)]
    RegulatedProfile(#[from] klieo_core::ProfileViolation),
}

// `error` is referenced by lib.rs; the module declaration in lib.rs must
// match the actual module we want exposed. T1's lib.rs declares
// `pub mod error;` so this file is the canonical location.