Skip to main content

klieo_a2a/
error.rs

1//! A2A error type with JSON-RPC code mapping.
2
3use crate::envelope::{codes, JsonRpcError, JsonRpcResponse};
4use thiserror::Error;
5
6/// Errors returned by the A2A server / handler.
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum A2aError {
10    /// JSON-RPC method name not recognised. Maps to JSON-RPC -32601.
11    #[error("method not found: {0}")]
12    MethodNotFound(String),
13    /// Params payload could not be decoded into the method's param type.
14    /// Maps to JSON-RPC -32602.
15    #[error("invalid params: {0}")]
16    InvalidParams(String),
17    /// JSON parse failure. Maps to JSON-RPC -32700.
18    #[error("json error: {0}")]
19    Json(#[from] serde_json::Error),
20    /// Bus-layer failure (subscribe / publish / KV).
21    #[error("bus error: {0}")]
22    Bus(#[from] klieo_core::error::BusError),
23    /// Task id not present in the store.
24    #[error("task not found: {0}")]
25    TaskNotFound(String),
26    /// Catch-all server-side failure. Maps to JSON-RPC -32000.
27    #[error("server error: {0}")]
28    Server(String),
29    /// The tenant-ownership store was unreachable, or returned an
30    /// inconclusive verdict, while gating a request under strict tenant
31    /// binding — the request is denied fail-closed rather than served
32    /// unprotected. Maps to JSON-RPC -32000; Display is redaction-safe
33    /// (carries no caller/principal/task detail on the wire — that lives
34    /// in the server-side log emitted before this is returned).
35    #[error("ownership store unavailable")]
36    OwnershipUnavailable,
37    /// Authentication or authorisation failure. Maps to A2A-specific -32001.
38    #[error("unauthorized: {0}")]
39    Unauthorized(String),
40    /// The server was started with an invalid or dangerous configuration
41    /// (e.g. non-loopback bind with an anonymous authenticator without the
42    /// explicit `allow_public_bind` opt-in). Maps to JSON-RPC -32000.
43    #[error("misconfigured: {0}")]
44    Misconfigured(String),
45    /// Resume requested with a cursor older than the oldest retained
46    /// event in the buffer. Maps to JSON-RPC application code
47    /// [`crate::envelope::codes::RESUME_BUFFER_EXPIRED`] (-32010).
48    #[error("resume window expired (since_id={since_id})")]
49    ResumeBufferExpired {
50        /// Cursor the caller supplied.
51        since_id: u64,
52    },
53    /// Server-side internal failure that preserves the underlying
54    /// typed cause via `Error::source`. Distinct from
55    /// [`A2aError::Server`] (bare string) because the source chain
56    /// matters for tracing.
57    ///
58    /// Maps to JSON-RPC SERVER_ERROR (`-32000`). Display is the
59    /// stable, redaction-safe string `"internal error"` — the inner
60    /// cause is reachable only via `Error::source()` so callers that
61    /// log server-side see the chain while wire envelopes carry no
62    /// internal detail. Use for server-internal serialization,
63    /// encode, or IPC failures that should NOT be mis-classified as
64    /// client-input parse errors.
65    #[error("internal error")]
66    Internal {
67        /// Underlying typed cause, preserved for `Error::source()`.
68        #[source]
69        source: Box<dyn std::error::Error + Send + Sync>,
70    },
71    /// The remote A2A peer answered with a JSON-RPC error envelope.
72    /// Raised client-side by [`crate::client::A2aClient`] when a reply
73    /// carries `error` instead of `result`; the `code`/`message` are the
74    /// peer's verbatim JSON-RPC error payload so the caller can branch on
75    /// the wire code (e.g. -32601 method-not-found, -32000 server error).
76    #[error("remote rpc error {code}: {message}")]
77    Rpc {
78        /// The peer's verbatim JSON-RPC error code — branch on it for the
79        /// wire-level cause (e.g. -32601 method-not-found, -32000 server).
80        code: i32,
81        /// The peer's verbatim error message; treat as untrusted display
82        /// text (it originated on the remote side).
83        message: String,
84    },
85    /// No reply arrived on the client's inbox before the per-call
86    /// deadline elapsed. Raised client-side by
87    /// [`crate::client::A2aClient`]; the bus accepted the publish but the
88    /// peer never answered (crashed, slow, or wrong agent id).
89    #[error("rpc timed out after {0:?}")]
90    Timeout(std::time::Duration),
91    /// Multi-replica orphan: the streaming leader for this task
92    /// died (TTL-expired KV claim) or never claimed leadership.
93    /// Maps to JSON-RPC application code
94    /// [`crate::envelope::codes::LEADER_DIED`] (`-32099`). The
95    /// dispatcher writes a terminal frame to the resume buffer
96    /// before raising so future resume clients see clean
97    /// termination + can retry. ADR-020.
98    #[error("stream leader died")]
99    LeaderDied {
100        /// Leader-registry stream id (e.g. `a2a.t-1`) so the
101        /// client can correlate with bus telemetry.
102        stream_id: String,
103    },
104}
105
106impl A2aError {
107    /// Map this error onto a [`JsonRpcResponse`] carrying the matching
108    /// JSON-RPC error code.
109    pub fn to_json_rpc_error(&self, request_id: serde_json::Value) -> JsonRpcResponse {
110        let (code, message) = match self {
111            Self::MethodNotFound(_) => (codes::METHOD_NOT_FOUND, self.to_string()),
112            Self::InvalidParams(_) => (codes::INVALID_PARAMS, self.to_string()),
113            Self::Json(_) => (codes::PARSE_ERROR, self.to_string()),
114            Self::Bus(_)
115            | Self::TaskNotFound(_)
116            | Self::Server(_)
117            | Self::OwnershipUnavailable
118            | Self::Misconfigured(_)
119            | Self::Internal { .. } => (codes::SERVER_ERROR, self.to_string()),
120            Self::Unauthorized(_) => (codes::UNAUTHENTICATED, self.to_string()),
121            // Client-side variants are never re-serialised onto the wire by
122            // a server, but the match must stay exhaustive: re-emit the
123            // peer's original code for `Rpc`, generic server error for a
124            // local timeout.
125            Self::Rpc { code, message } => (*code, message.clone()),
126            Self::Timeout(_) => (codes::SERVER_ERROR, self.to_string()),
127            Self::ResumeBufferExpired { .. } => (codes::RESUME_BUFFER_EXPIRED, self.to_string()),
128            Self::LeaderDied { .. } => (codes::LEADER_DIED, self.to_string()),
129        };
130        let data = match self {
131            Self::LeaderDied { stream_id } => Some(serde_json::json!({
132                "stream_id": stream_id,
133            })),
134            _ => None,
135        };
136        JsonRpcResponse {
137            jsonrpc: "2.0".to_string(),
138            id: request_id,
139            result: None,
140            error: Some(JsonRpcError {
141                code,
142                message,
143                data,
144            }),
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153
154    #[test]
155    fn method_not_found_maps_to_minus_32601() {
156        let resp = A2aError::MethodNotFound("Foo".into()).to_json_rpc_error(json!(7));
157        assert_eq!(resp.error.unwrap().code, -32601);
158    }
159
160    #[test]
161    fn invalid_params_maps_to_minus_32602() {
162        let resp = A2aError::InvalidParams("missing field".into()).to_json_rpc_error(json!("a"));
163        assert_eq!(resp.error.unwrap().code, -32602);
164    }
165
166    #[test]
167    fn json_error_maps_to_minus_32700() {
168        let json_err = serde_json::from_str::<u32>("not json").unwrap_err();
169        let resp = A2aError::from(json_err).to_json_rpc_error(json!(null));
170        assert_eq!(resp.error.unwrap().code, -32700);
171    }
172
173    #[test]
174    fn unauthorized_maps_to_minus_32001() {
175        let resp = A2aError::Unauthorized("no token".into()).to_json_rpc_error(json!(1));
176        assert_eq!(resp.error.unwrap().code, -32001);
177    }
178
179    #[test]
180    fn resume_buffer_expired_renders_message() {
181        let e = A2aError::ResumeBufferExpired { since_id: 42 };
182        assert_eq!(e.to_string(), "resume window expired (since_id=42)");
183    }
184
185    #[test]
186    fn resume_buffer_expired_maps_to_minus_32010() {
187        let resp = A2aError::ResumeBufferExpired { since_id: 99 }.to_json_rpc_error(json!(1));
188        assert_eq!(resp.error.unwrap().code, -32010);
189    }
190
191    #[test]
192    fn internal_preserves_source_and_maps_to_server_error() {
193        use std::error::Error as _;
194        let inner = std::io::Error::other("synthetic encode failure");
195        let err = A2aError::Internal {
196            source: Box::new(inner),
197        };
198        assert!(err.source().is_some(), "Internal must preserve source");
199        assert_eq!(
200            err.to_string(),
201            "internal error",
202            "Display must be redaction-safe stable string, not surface inner Display",
203        );
204        let resp = err.to_json_rpc_error(json!(1));
205        assert_eq!(resp.error.unwrap().code, codes::SERVER_ERROR);
206    }
207
208    #[test]
209    fn misconfigured_maps_to_server_error() {
210        let err = A2aError::Misconfigured("startup config rejected".into());
211        let resp = err.to_json_rpc_error(json!(1));
212        let inner = resp.error.expect("error envelope");
213        assert_eq!(inner.code, codes::SERVER_ERROR);
214        assert!(
215            inner
216                .message
217                .to_lowercase()
218                .contains("startup config rejected")
219                || inner.message.to_lowercase().contains("misconfigured"),
220            "expected message to surface details; got: {}",
221            inner.message
222        );
223    }
224}
225
226/// Errors returned by [`crate::server::A2aDispatcherBuilder::build`] and
227/// [`crate::server::A2aDispatcherBuilder::build_arc`].
228#[derive(Debug, thiserror::Error)]
229#[non_exhaustive]
230pub enum A2aBuilderError {
231    /// `with_cancel_subscription` was set but `build()` (not `build_arc()`) was called.
232    #[error("with_cancel_subscription requires build_arc()")]
233    CancelRequiresArc,
234    /// `handler(...)` was not called on the builder.
235    #[error("handler required; call .handler() before build")]
236    MissingHandler,
237    /// `authenticator(...)` was not called on the builder.
238    #[error("authenticator required; call .authenticator() before build")]
239    MissingAuthenticator,
240    /// `pubsub(...)` or `with_in_process_pubsub()` was not called on the builder.
241    #[error("pubsub required; call .pubsub() or .with_in_process_pubsub() before build")]
242    MissingPubsub,
243    /// `kv(...)` was not called on [`crate::http::A2aHttpServerBuilder`].
244    /// The HTTP server's task store is backed by a [`klieo_core::KvStore`];
245    /// without one there is nowhere to persist tasks for `SubscribeToTask`
246    /// current-state replay.
247    #[error("kv required; call .kv() before build")]
248    MissingKv,
249    /// The builder's `profile(..)` requirements were not met (e.g. regulated
250    /// profile without tenant binding or with an anonymous authenticator).
251    #[error(transparent)]
252    RegulatedProfile(#[from] klieo_core::ProfileViolation),
253}
254
255// `error` is referenced by lib.rs; the module declaration in lib.rs must
256// match the actual module we want exposed. T1's lib.rs declares
257// `pub mod error;` so this file is the canonical location.