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.