1use std::fmt;
12
13use a2a_protocol_types::{A2aError, TaskId};
14
15#[derive(Debug)]
19#[non_exhaustive]
20pub enum ClientError {
21 Http(hyper::Error),
23
24 HttpClient(String),
26
27 Serialization(serde_json::Error),
29
30 Protocol(A2aError),
32
33 Transport(String),
35
36 InvalidEndpoint(String),
38
39 UnexpectedStatus {
41 status: u16,
43 body: String,
45 retry_after: Option<std::time::Duration>,
50 },
51
52 AuthRequired {
54 task_id: TaskId,
56 },
57
58 Timeout(String),
60
61 ProtocolBindingMismatch(String),
67}
68
69impl fmt::Display for ClientError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Http(e) => write!(f, "HTTP error: {e}"),
73 Self::HttpClient(msg) => write!(f, "HTTP client error: {msg}"),
74 Self::Serialization(e) => write!(f, "serialization error: {e}"),
75 Self::Protocol(e) => write!(f, "protocol error: {e}"),
76 Self::Transport(msg) => write!(f, "transport error: {msg}"),
77 Self::InvalidEndpoint(msg) => write!(f, "invalid endpoint: {msg}"),
78 Self::UnexpectedStatus { status, body, .. } => {
79 write!(f, "unexpected HTTP status {status}: {body}")
80 }
81 Self::AuthRequired { task_id } => {
82 write!(f, "authentication required for task: {task_id}")
83 }
84 Self::Timeout(msg) => write!(f, "timeout: {msg}"),
85 Self::ProtocolBindingMismatch(msg) => {
86 write!(
87 f,
88 "protocol binding mismatch: {msg}; check the agent card's supported_interfaces"
89 )
90 }
91 }
92 }
93}
94
95impl std::error::Error for ClientError {
96 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
97 match self {
98 Self::Http(e) => Some(e),
99 Self::Serialization(e) => Some(e),
100 Self::Protocol(e) => Some(e),
101 _ => None,
102 }
103 }
104}
105
106impl ClientError {
107 #[must_use]
111 pub const fn retry_after(&self) -> Option<std::time::Duration> {
112 match self {
113 Self::UnexpectedStatus { retry_after, .. } => *retry_after,
114 _ => None,
115 }
116 }
117}
118
119#[must_use]
125pub(crate) fn parse_retry_after(headers: &hyper::HeaderMap) -> Option<std::time::Duration> {
126 let raw = headers.get(hyper::header::RETRY_AFTER)?.to_str().ok()?;
127 let secs: u64 = raw.trim().parse().ok()?;
128 Some(std::time::Duration::from_secs(secs.min(3600)))
131}
132
133impl From<A2aError> for ClientError {
134 fn from(e: A2aError) -> Self {
135 Self::Protocol(e)
136 }
137}
138
139impl From<hyper::Error> for ClientError {
140 fn from(e: hyper::Error) -> Self {
141 Self::Http(e)
142 }
143}
144
145impl From<serde_json::Error> for ClientError {
146 fn from(e: serde_json::Error) -> Self {
147 Self::Serialization(e)
148 }
149}
150
151pub type ClientResult<T> = Result<T, ClientError>;
155
156#[cfg(test)]
159mod tests {
160 use super::*;
161 use a2a_protocol_types::ErrorCode;
162
163 #[test]
164 fn client_error_display_http_client() {
165 let e = ClientError::HttpClient("connection refused".into());
166 assert!(e.to_string().contains("connection refused"));
167 }
168
169 #[test]
170 fn client_error_display_protocol() {
171 let a2a = A2aError::task_not_found("task-99");
172 let e = ClientError::Protocol(a2a);
173 assert!(e.to_string().contains("task-99"));
174 }
175
176 #[test]
177 fn client_error_from_a2a_error() {
178 let a2a = A2aError::new(ErrorCode::TaskNotFound, "missing");
179 let e: ClientError = a2a.into();
180 assert!(matches!(e, ClientError::Protocol(_)));
181 }
182
183 #[test]
184 fn client_error_unexpected_status() {
185 let e = ClientError::UnexpectedStatus {
186 status: 404,
187 body: "Not Found".into(),
188 retry_after: None,
189 };
190 assert!(e.to_string().contains("404"));
191 }
192
193 #[test]
199 fn timeout_is_retryable_transport_is_not() {
200 let timeout = ClientError::Timeout("request timed out".into());
201 assert!(timeout.is_retryable(), "Timeout errors must be retryable");
202
203 let transport = ClientError::Transport("config error".into());
204 assert!(
205 !transport.is_retryable(),
206 "Transport errors must not be retryable"
207 );
208 }
209
210 #[test]
211 fn client_error_source_http() {
212 use std::error::Error;
213 let http_err: ClientError = ClientError::HttpClient("test".into());
216 assert!(http_err.source().is_none());
218
219 let ser_err =
221 ClientError::Serialization(serde_json::from_str::<String>("not json").unwrap_err());
222 assert!(
223 ser_err.source().is_some(),
224 "Serialization error should have a source"
225 );
226
227 let proto_err = ClientError::Protocol(a2a_protocol_types::A2aError::task_not_found("t"));
229 assert!(
230 proto_err.source().is_some(),
231 "Protocol error should have a source"
232 );
233
234 let transport_err = ClientError::Transport("config".into());
236 assert!(transport_err.source().is_none());
237 }
238
239 #[test]
242 fn client_error_display_transport() {
243 let e = ClientError::Transport("socket closed".into());
244 let s = e.to_string();
245 assert!(s.contains("transport error"), "missing prefix: {s}");
246 assert!(s.contains("socket closed"), "missing message: {s}");
247 }
248
249 #[test]
250 fn client_error_display_invalid_endpoint() {
251 let e = ClientError::InvalidEndpoint("bad url".into());
252 let s = e.to_string();
253 assert!(s.contains("invalid endpoint"), "missing prefix: {s}");
254 assert!(s.contains("bad url"), "missing message: {s}");
255 }
256
257 #[test]
258 fn client_error_display_auth_required() {
259 let e = ClientError::AuthRequired {
260 task_id: TaskId::new("task-7"),
261 };
262 let s = e.to_string();
263 assert!(s.contains("authentication required"), "missing prefix: {s}");
264 assert!(s.contains("task-7"), "missing task_id: {s}");
265 }
266
267 #[test]
268 fn client_error_display_timeout() {
269 let e = ClientError::Timeout("30s elapsed".into());
270 let s = e.to_string();
271 assert!(s.contains("timeout"), "missing prefix: {s}");
272 assert!(s.contains("30s elapsed"), "missing message: {s}");
273 }
274
275 #[test]
276 fn client_error_display_protocol_binding_mismatch() {
277 let e = ClientError::ProtocolBindingMismatch("expected REST".into());
278 let s = e.to_string();
279 assert!(
280 s.contains("protocol binding mismatch"),
281 "missing prefix: {s}"
282 );
283 assert!(s.contains("expected REST"), "missing message: {s}");
284 assert!(s.contains("supported_interfaces"), "missing advice: {s}");
285 }
286
287 #[test]
288 fn client_error_display_serialization() {
289 let e = ClientError::Serialization(serde_json::from_str::<String>("bad").unwrap_err());
290 let s = e.to_string();
291 assert!(s.contains("serialization error"), "missing prefix: {s}");
292 }
293
294 #[test]
295 fn client_error_display_unexpected_status() {
296 let e = ClientError::UnexpectedStatus {
297 status: 500,
298 body: "Internal Server Error".into(),
299 retry_after: None,
300 };
301 let s = e.to_string();
302 assert!(s.contains("500"), "missing status code: {s}");
303 assert!(s.contains("Internal Server Error"), "missing body: {s}");
304 }
305
306 #[test]
309 fn client_error_source_none_for_string_variants() {
310 use std::error::Error;
311 let cases: Vec<ClientError> = vec![
312 ClientError::HttpClient("msg".into()),
313 ClientError::Transport("msg".into()),
314 ClientError::InvalidEndpoint("msg".into()),
315 ClientError::UnexpectedStatus {
316 status: 404,
317 body: String::new(),
318 retry_after: None,
319 },
320 ClientError::AuthRequired {
321 task_id: TaskId::new("t"),
322 },
323 ClientError::Timeout("msg".into()),
324 ClientError::ProtocolBindingMismatch("msg".into()),
325 ];
326 for e in &cases {
327 assert!(
328 e.source().is_none(),
329 "{:?} should have no source",
330 std::mem::discriminant(e)
331 );
332 }
333 }
334
335 #[tokio::test]
339 async fn client_error_display_and_source_http() {
340 use http_body_util::{BodyExt, Full};
341 use hyper::body::Bytes;
342 use tokio::io::AsyncWriteExt;
343
344 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
347 let addr = listener.local_addr().unwrap();
348
349 tokio::spawn(async move {
350 let (mut stream, _) = listener.accept().await.unwrap();
351 let mut buf = [0u8; 4096];
353 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
354 let resp = "HTTP/1.1 200 OK\r\ncontent-length: 1000\r\n\r\nhello";
356 let _ = stream.write_all(resp.as_bytes()).await;
357 drop(stream);
359 });
360
361 let client: hyper_util::client::legacy::Client<
362 hyper_util::client::legacy::connect::HttpConnector,
363 Full<Bytes>,
364 > = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
365 .build(hyper_util::client::legacy::connect::HttpConnector::new());
366
367 let req = hyper::Request::builder()
368 .uri(format!("http://127.0.0.1:{}", addr.port()))
369 .body(Full::new(Bytes::new()))
370 .unwrap();
371
372 let resp = client.request(req).await.unwrap();
373 let body_result = resp.collect().await;
375 if let Err(hyper_err) = body_result {
376 use std::error::Error;
377
378 let client_err: ClientError = ClientError::Http(hyper_err);
380
381 let display = client_err.to_string();
383 assert!(display.contains("HTTP error"), "Display: {display}");
384
385 assert!(
387 client_err.source().is_some(),
388 "Http variant should have a source"
389 );
390 } else {
391 }
394 }
395
396 #[test]
399 fn client_error_from_serde_json_error() {
400 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
401 let e: ClientError = serde_err.into();
402 assert!(matches!(e, ClientError::Serialization(_)));
403 }
404
405 #[test]
407 fn retryable_classification_exhaustive() {
408 assert!(ClientError::HttpClient("conn reset".into()).is_retryable());
410 assert!(ClientError::Timeout("deadline".into()).is_retryable());
411 assert!(ClientError::UnexpectedStatus {
412 status: 429,
413 body: String::new(),
414 retry_after: None,
415 }
416 .is_retryable());
417 assert!(ClientError::UnexpectedStatus {
418 status: 502,
419 body: String::new(),
420 retry_after: None,
421 }
422 .is_retryable());
423 assert!(ClientError::UnexpectedStatus {
424 status: 503,
425 body: String::new(),
426 retry_after: None,
427 }
428 .is_retryable());
429 assert!(ClientError::UnexpectedStatus {
430 status: 504,
431 body: String::new(),
432 retry_after: None,
433 }
434 .is_retryable());
435
436 assert!(!ClientError::Transport("bad config".into()).is_retryable());
438 assert!(!ClientError::InvalidEndpoint("bad url".into()).is_retryable());
439 assert!(!ClientError::UnexpectedStatus {
440 status: 400,
441 body: String::new(),
442 retry_after: None,
443 }
444 .is_retryable());
445 assert!(!ClientError::UnexpectedStatus {
446 status: 401,
447 body: String::new(),
448 retry_after: None,
449 }
450 .is_retryable());
451 assert!(!ClientError::UnexpectedStatus {
452 status: 404,
453 body: String::new(),
454 retry_after: None,
455 }
456 .is_retryable());
457 assert!(!ClientError::ProtocolBindingMismatch("wrong".into()).is_retryable());
458 assert!(!ClientError::AuthRequired {
459 task_id: TaskId::new("t")
460 }
461 .is_retryable());
462 }
463}