zeph_a2a/error.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Error types for A2A client, server, and discovery operations.
5
6use crate::jsonrpc::JsonRpcError;
7
8/// All errors that can occur in A2A client and server operations.
9///
10/// The variants map to distinct failure modes so callers can recover appropriately:
11/// - Retry on [`Http`](A2aError::Http) (transient network issues).
12/// - Inspect the code on [`JsonRpc`](A2aError::JsonRpc) — `-32001` is task-not-found,
13/// `-32002` is not-cancelable.
14/// - Abort on [`Security`](A2aError::Security) — endpoint rejected by TLS or SSRF policy.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum A2aError {
18 /// A `reqwest` HTTP transport error (connection refused, timeout, TLS, etc.).
19 #[error("HTTP request failed: {0}")]
20 Http(#[from] reqwest::Error),
21
22 /// JSON serialization or deserialization failure.
23 #[error("JSON serialization/deserialization failed: {0}")]
24 Json(#[from] serde_json::Error),
25
26 /// The remote agent returned a JSON-RPC error object.
27 ///
28 /// Well-known codes defined by the A2A spec:
29 /// - `-32001`: task not found
30 /// - `-32002`: task not in a cancelable state
31 #[error("JSON-RPC error {code}: {message}")]
32 JsonRpc { code: i32, message: String },
33
34 /// `AgentRegistry` could not retrieve a valid [`AgentCard`](crate::types::AgentCard)
35 /// from the remote agent's well-known URL.
36 #[error("agent discovery failed for {url}: {reason}")]
37 Discovery { url: String, reason: String },
38
39 /// An error occurred while reading the SSE event stream from a streaming call.
40 #[error("SSE stream error: {0}")]
41 Stream(String),
42
43 /// An internal server-side error (binding failure, task processing panic, etc.).
44 #[error("server error: {0}")]
45 Server(String),
46
47 /// A request was rejected by the client's security policy.
48 ///
49 /// Triggered when [`A2aClient`](crate::A2aClient) is configured with
50 /// `require_tls = true` and an `http://` endpoint is used, or when
51 /// `ssrf_protection = true` and DNS resolves to a private/loopback address.
52 #[error("security policy violation: {0}")]
53 Security(String),
54
55 /// A request or task processing operation exceeded its deadline.
56 #[error("operation timed out after {0:?}")]
57 Timeout(std::time::Duration),
58
59 /// A discovered [`AgentCard`](crate::types::AgentCard) failed the configured
60 /// [`CardTrustPolicy`](crate::discovery::CardTrustPolicy) signature check.
61 ///
62 /// Returned when the signature axis alone, or the signature axis combined with a
63 /// URL-origin mismatch, causes rejection (S2: signature `Invalid` dominates a URL
64 /// mismatch). See [`UrlMismatch`](A2aError::UrlMismatch) for a URL-only rejection.
65 #[error("untrusted agent card: {reason}")]
66 UntrustedCard {
67 /// Human-readable reason, e.g. an ECDSA verification failure or an unknown `kid`.
68 reason: String,
69 },
70
71 /// A discovered [`AgentCard`](crate::types::AgentCard)'s `url` field origin
72 /// (scheme + host + port) does not match the origin that was queried, and the
73 /// signature axis did not independently trigger rejection.
74 #[error("agent card url mismatch: queried '{queried}', card advertises '{advertised}'")]
75 UrlMismatch {
76 /// Origin that was queried (`scheme://host:port`).
77 queried: String,
78 /// Origin advertised by the card's `url` field (`scheme://host:port`).
79 advertised: String,
80 },
81}
82
83impl From<JsonRpcError> for A2aError {
84 fn from(e: JsonRpcError) -> Self {
85 Self::JsonRpc {
86 code: e.code,
87 message: e.message,
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::assert_matches;
96
97 #[test]
98 fn from_jsonrpc_error() {
99 let rpc_err = JsonRpcError {
100 code: -32001,
101 message: "task not found".into(),
102 data: None,
103 };
104 let err: A2aError = rpc_err.into();
105 match err {
106 A2aError::JsonRpc { code, message } => {
107 assert_eq!(code, -32001);
108 assert_eq!(message, "task not found");
109 }
110 _ => panic!("expected JsonRpc variant"),
111 }
112 }
113
114 #[test]
115 fn error_display() {
116 let err = A2aError::Discovery {
117 url: "http://example.com".into(),
118 reason: "connection refused".into(),
119 };
120 assert_eq!(
121 err.to_string(),
122 "agent discovery failed for http://example.com: connection refused"
123 );
124
125 let err = A2aError::Stream("unexpected EOF".into());
126 assert_eq!(err.to_string(), "SSE stream error: unexpected EOF");
127 }
128
129 #[test]
130 fn security_error_display() {
131 let err = A2aError::Security("TLS required but endpoint uses HTTP".into());
132 assert_eq!(
133 err.to_string(),
134 "security policy violation: TLS required but endpoint uses HTTP"
135 );
136 }
137
138 #[test]
139 fn from_serde_json_error() {
140 let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
141 let err: A2aError = json_err.into();
142 assert_matches!(err, A2aError::Json(_));
143 }
144}