Skip to main content

lc_a2a/client/
mod.rs

1//! A2A Client - connects to remote A2A agents over HTTP.
2//!
3//! The client uses `reqwest` (already in the project dependencies) to
4//! communicate with A2A servers. It supports:
5//!
6//! - Fetching an agent card (`GET /.well-known/agent-card.json`), with
7//!   URL-consistency and optional HMAC signature verification (P1-3)
8//! - Sending a task (`tasks/send`), optionally idempotent via `message_id`
9//!   (P1-6) and carrying a distributed `trace_id` (P1-5)
10//! - Polling a task to completion (`tasks/get` via `send_task_and_wait`),
11//!   surfacing the `input-required` state to the caller (P2-3)
12//! - Resuming an `input-required` task with the client's answer
13//!   (`resume_task`)
14//! - Cancelling a task (`tasks/cancel`)
15//! - Streaming task progress over SSE (`send_task_streaming` / `connect_sse`,
16//!   P2-1)
17//!
18//! Requests carry a per-request timeout by default, and the builder can
19//! enforce HTTPS for production deployments.
20//!
21//! # Example
22//!
23//! ```ignore
24//! use lc_a2a::{A2AClient, A2AMessage};
25//!
26//! let client = A2AClient::new("https://agent.example.com".to_string()).unwrap();
27//! let card = client.get_agent_card().await?;
28//! let task = client.send_task(A2AMessage::user("hello")).await?;
29//! ```
30
31// `pub(crate)` so the server can reuse `constant_time_eq` for bearer-token
32// checks (0.22.0 audit fix).
33pub(crate) mod signing;
34mod sse;
35
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::time::Duration;
38
39use super::protocol::{
40    A2AErrorData, A2AMessage, A2ARequest, A2AResponse, A2ATask, A2ATaskDetails, A2ATaskResult,
41    AgentCard, TaskStatus, TraceContext,
42};
43
44pub use signing::{
45    canonical_json, sign_agent_card, sign_card_jws, verify_card_jws, verify_card_signature,
46};
47pub use sse::A2ASseStream;
48
49/// Errors that can occur during A2A client operations.
50#[derive(Debug, thiserror::Error)]
51#[non_exhaustive]
52pub enum A2AError {
53    /// HTTP transport error.
54    #[error("HTTP error: {0}")]
55    Http(String),
56
57    /// JSON parse error.
58    #[error("Parse error: {0}")]
59    Parse(String),
60
61    /// API-level error (returned by the remote agent).
62    #[error("API error [{code}]: {message}")]
63    Api {
64        /// The JSON-RPC error code.
65        code: i32,
66        /// Human-readable error message.
67        message: String,
68    },
69
70    /// Request timed out.
71    #[error("Timeout: {0}")]
72    Timeout(String),
73
74    /// Agent card signature verification failed, or a signed card could not be
75    /// verified (P1-3).
76    #[error("Agent card signature: {0}")]
77    Signature(String),
78
79    /// The agent needs more information before it can continue (P2-3).
80    ///
81    /// Resume the conversation with [`A2AClient::resume_task`].
82    #[error("Task {task_id} requires more input: {prompt}")]
83    InputRequired {
84        /// ID of the task requiring more input.
85        task_id: String,
86        /// Prompt describing what additional input is needed.
87        prompt: String,
88    },
89}
90
91impl From<reqwest::Error> for A2AError {
92    fn from(err: reqwest::Error) -> Self {
93        if err.is_timeout() {
94            A2AError::Timeout(err.to_string())
95        } else {
96            A2AError::Http(err.to_string())
97        }
98    }
99}
100
101impl From<A2AErrorData> for A2AError {
102    fn from(err: A2AErrorData) -> Self {
103        A2AError::Api {
104            code: err.code,
105            message: err.message,
106        }
107    }
108}
109
110/// A2A Client - communicates with remote A2A agents.
111pub struct A2AClient {
112    /// Base URL of the remote agent (e.g. "http://localhost:8080").
113    base_url: String,
114    /// HTTP client for regular RPC requests (bounded total timeout).
115    http: reqwest::Client,
116    /// HTTP client for SSE/streaming requests: no total timeout so a
117    /// long-lived stream is not cut off (0.22.0 audit fix H-P4). Connect
118    /// timeout still applies.
119    stream_http: reqwest::Client,
120    /// Monotonic request ID counter.
121    next_id: AtomicU64,
122    /// Optional bearer token sent on every request.
123    auth_token: Option<String>,
124    /// Distributed trace id attached to every request's metadata (P1-5).
125    trace_id: Option<String>,
126    /// W3C trace context sent as a `traceparent` header (P2-8).
127    trace_context: Option<TraceContext>,
128    /// Optional secret used to verify `AgentCard` signatures (P1-3).
129    card_secret: Option<Vec<u8>>,
130    /// Reject signed cards that cannot be verified (P1-3).
131    require_card_signature: bool,
132}
133
134impl A2AClient {
135    /// Create a new client targeting the given base URL.
136    ///
137    /// Uses a 30s per-request timeout and a 10s connect timeout. The client
138    /// is safe to share and call concurrently; each request gets its own ID.
139    ///
140    /// Returns an error if the HTTP client cannot be built (e.g. the TLS
141    /// backend fails to initialize). For full configuration, use
142    /// [`builder`](Self::builder) instead.
143    pub fn new(base_url: impl Into<String>) -> Result<Self, A2AError> {
144        let base_url = base_url.into();
145        if !base_url.starts_with("https://") {
146            log::warn!(
147                "A2A client connecting over non-HTTPS URL: {} (use TLS in production)",
148                base_url
149            );
150        }
151        let http = reqwest::Client::builder()
152            .timeout(Duration::from_secs(30))
153            .connect_timeout(Duration::from_secs(10))
154            .build()
155            .map_err(|e| A2AError::Http(format!("failed to build HTTP client: {e}")))?;
156        // 0.22.0 audit fix (H-P4): the 30s total timeout above covers the whole
157        // body read, so an SSE stream would be cut off at 30s. Streaming uses
158        // a client with only a connect timeout; fall back to the RPC client if
159        // this one cannot be built.
160        let stream_http = reqwest::Client::builder()
161            .connect_timeout(Duration::from_secs(10))
162            .build()
163            .unwrap_or_else(|_| http.clone());
164        Ok(Self::with_http_client(base_url, http).with_stream_client(stream_http))
165    }
166
167    /// Create a client with a custom `reqwest::Client` (for timeouts, etc.).
168    ///
169    /// The given client is used for both RPC and streaming requests; if it
170    /// carries a total timeout, long-lived SSE streams will be cut off — the
171    /// caller owns that trade-off.
172    pub fn with_http_client(base_url: impl Into<String>, http: reqwest::Client) -> Self {
173        Self {
174            base_url: base_url.into().trim_end_matches('/').to_string(),
175            stream_http: http.clone(),
176            http,
177            next_id: AtomicU64::new(1),
178            auth_token: None,
179            trace_id: None,
180            trace_context: None,
181            card_secret: None,
182            require_card_signature: false,
183        }
184    }
185
186    /// Replace the client used for SSE/streaming requests (0.22.0 audit fix
187    /// H-P4). Typically a timeout-free client with a connect timeout.
188    fn with_stream_client(mut self, stream_http: reqwest::Client) -> Self {
189        self.stream_http = stream_http;
190        self
191    }
192
193    /// Start building a client with full configuration.
194    pub fn builder(base_url: impl Into<String>) -> A2AClientBuilder {
195        A2AClientBuilder::new(base_url)
196    }
197
198    /// Allocate the next request ID.
199    fn alloc_id(&self) -> u64 {
200        self.next_id.fetch_add(1, Ordering::SeqCst)
201    }
202
203    /// Fetch the agent card from `GET /.well-known/agent-card.json`.
204    ///
205    /// Performs two integrity checks (P1-3):
206    ///
207    /// - **URL consistency**: if the card advertises a `url` that differs from
208    ///   the base URL this client was pointed at, a warning is logged. The card
209    ///   is still returned — a load-balanced deployment legitimately advertises
210    ///   a public URL different from the node you reached.
211    /// - **Signature**: if the card carries a `signature` and a verification
212    ///   secret is configured, the signature is verified and a mismatch is a
213    ///   hard error. With `require_card_signature`, a signed card with no
214    ///   secret configured is also rejected. Unsigned cards pass through.
215    pub async fn get_agent_card(&self) -> Result<AgentCard, A2AError> {
216        let url = format!("{}/.well-known/agent-card.json", self.base_url);
217        let resp = self.with_traceparent(self.http.get(&url)).send().await?;
218        let status = resp.status();
219        if !status.is_success() {
220            return Err(A2AError::Http(format!(
221                "Agent card request failed with status {}",
222                status
223            )));
224        }
225        let card: AgentCard = resp
226            .json()
227            .await
228            .map_err(|e| A2AError::Parse(format!("Failed to parse agent card: {}", e)))?;
229
230        // URL consistency check (warn-only; see doc comment).
231        if !card.url.trim_end_matches('/').is_empty()
232            && card.url.trim_end_matches('/') != self.base_url.trim_end_matches('/')
233        {
234            log::warn!(
235                "Agent card URL mismatch: card.url={}, base_url={}",
236                card.url,
237                self.base_url
238            );
239        }
240
241        // Signature verification.
242        if card.signature.is_some() {
243            match &self.card_secret {
244                Some(secret) => {
245                    verify_card_signature(&card, secret)?;
246                }
247                None if self.require_card_signature => {
248                    return Err(A2AError::Signature(
249                        "agent card is signed but no verification secret is configured".to_string(),
250                    ));
251                }
252                None => {
253                    log::warn!(
254                        "agent card is signed but no verification secret is configured; \
255                         skipping signature verification"
256                    );
257                }
258            }
259        }
260
261        Ok(card)
262    }
263
264    /// Send a task to the remote agent (`tasks/send`).
265    ///
266    /// The request carries the client's `trace_id`, if configured (P1-5).
267    pub async fn send_task(&self, message: A2AMessage) -> Result<A2ATask, A2AError> {
268        let id = self.alloc_id();
269        let req = self.with_context(A2ARequest::send_task(id, &message));
270        self.send_task_req(req).await
271    }
272
273    /// Send a task with an explicit `message_id` so a retried call returns the
274    /// already-created task instead of running the chain twice (P1-6).
275    pub async fn send_task_with_message_id(
276        &self,
277        message: A2AMessage,
278        message_id: &str,
279    ) -> Result<A2ATask, A2AError> {
280        let id = self.alloc_id();
281        let req = self.with_context(A2ARequest::send_task_with_message_id(
282            id, &message, message_id,
283        ));
284        self.send_task_req(req).await
285    }
286
287    /// Send a message to continue an existing `input-required` task, resuming
288    /// it back to `working` (P2-3).
289    ///
290    /// Equivalent to `tasks/send` carrying a `taskId`.
291    pub async fn resume_task(
292        &self,
293        task_id: &str,
294        message: A2AMessage,
295    ) -> Result<A2ATask, A2AError> {
296        let id = self.alloc_id();
297        let req = self.with_context(A2ARequest::continue_task(id, task_id, &message));
298        self.send_task_req(req).await
299    }
300
301    /// Get a task by ID (`tasks/get`).
302    pub async fn get_task(&self, task_id: &str) -> Result<A2ATask, A2AError> {
303        let id = self.alloc_id();
304        let req = self.with_context(A2ARequest::get_task(id, task_id));
305        let resp = self.post_request(req).await?;
306        self.task_from_response(resp)
307    }
308
309    /// Get a task by ID including its result and error (`tasks/get`).
310    pub async fn get_task_details(&self, task_id: &str) -> Result<A2ATaskDetails, A2AError> {
311        let id = self.alloc_id();
312        let req = self.with_context(A2ARequest::get_task(id, task_id));
313        let resp = self.post_request(req).await?;
314
315        let result = resp.into_result().map_err(A2AError::from)?;
316        let task: A2ATask = result
317            .get("task")
318            .ok_or_else(|| A2AError::Parse("Missing 'task' in response".to_string()))
319            .and_then(|v| {
320                serde_json::from_value(v.clone())
321                    .map_err(|e| A2AError::Parse(format!("Failed to parse task: {}", e)))
322            })?;
323        let task_result: Option<A2ATaskResult> = result
324            .get("result")
325            .map(|v| {
326                serde_json::from_value(v.clone())
327                    .map_err(|e| A2AError::Parse(format!("Failed to parse task result: {}", e)))
328            })
329            .transpose()?;
330        let error = result
331            .get("error")
332            .and_then(|v| v.as_str())
333            .map(|s| s.to_string());
334
335        Ok(A2ATaskDetails {
336            task,
337            result: task_result,
338            error,
339        })
340    }
341
342    /// Cancel a task by ID (`tasks/cancel`).
343    pub async fn cancel_task(&self, task_id: &str) -> Result<A2ATask, A2AError> {
344        let id = self.alloc_id();
345        let req = self.with_context(A2ARequest::cancel_task(id, task_id));
346        let resp = self.post_request(req).await?;
347        self.task_from_response(resp)
348    }
349
350    /// Attach the client's trace context to a request (P1-5).
351    fn with_context(&self, req: A2ARequest) -> A2ARequest {
352        match &self.trace_id {
353            Some(tid) => req.with_trace_id(tid.as_str()),
354            None => req,
355        }
356    }
357
358    /// Apply the W3C `traceparent` header to a request builder, when a trace
359    /// context is configured (P2-8).
360    fn with_traceparent(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
361        match &self.trace_context {
362            Some(ctx) => request.header("traceparent", ctx.to_traceparent()),
363            None => request,
364        }
365    }
366
367    /// POST a `tasks/send`-family request and extract the returned task.
368    async fn send_task_req(&self, req: A2ARequest) -> Result<A2ATask, A2AError> {
369        let resp = self.post_request(req).await?;
370        self.task_from_response(resp)
371    }
372
373    /// Extract the `task` from a successful A2A response.
374    fn task_from_response(&self, resp: A2AResponse) -> Result<A2ATask, A2AError> {
375        let result = resp.into_result().map_err(A2AError::from)?;
376        result
377            .get("task")
378            .ok_or_else(|| A2AError::Parse("Missing 'task' in response".to_string()))
379            .and_then(|v| {
380                serde_json::from_value(v.clone())
381                    .map_err(|e| A2AError::Parse(format!("Failed to parse task: {}", e)))
382            })
383    }
384
385    /// Send a task and poll `tasks/get` until it reaches a terminal state.
386    ///
387    /// Returns the task result on `completed`, an error on `failed` /
388    /// `cancelled` / `rejected` / `expired`, an [`A2AError::InputRequired`]
389    /// when the agent asks for more information (resume with
390    /// [`A2AClient::resume_task`]), or a [`A2AError::Timeout`] if the task does
391    /// not complete within `timeout`.
392    pub async fn send_task_and_wait(
393        &self,
394        message: A2AMessage,
395        timeout: Duration,
396    ) -> Result<A2ATaskResult, A2AError> {
397        let task = self.send_task(message).await?;
398        self.wait_for_task(&task.id, timeout).await
399    }
400
401    /// Idempotent variant of [`A2AClient::send_task_and_wait`]: sends the task
402    /// with a `message_id` (P1-6) so retries never create duplicate tasks.
403    pub async fn send_task_and_wait_with_message_id(
404        &self,
405        message: A2AMessage,
406        message_id: &str,
407        timeout: Duration,
408    ) -> Result<A2ATaskResult, A2AError> {
409        let task = self.send_task_with_message_id(message, message_id).await?;
410        self.wait_for_task(&task.id, timeout).await
411    }
412
413    /// Poll `tasks/get` until the task reaches a terminal state, surfacing
414    /// `input-required` to the caller (P2-3).
415    async fn wait_for_task(
416        &self,
417        task_id: &str,
418        timeout: Duration,
419    ) -> Result<A2ATaskResult, A2AError> {
420        let start = std::time::Instant::now();
421        let poll_interval = Duration::from_secs(1);
422
423        loop {
424            // 0.22.0 audit fix: a single transient network hiccup used to fail
425            // the whole poll. Retry each GET a few times with a small backoff
426            // before giving up; only a task in a terminal state ends the poll.
427            let mut details = None;
428            let mut last_err: Option<A2AError> = None;
429            for attempt in 0..3u32 {
430                match self.get_task_details(task_id).await {
431                    Ok(d) => {
432                        details = Some(d);
433                        break;
434                    }
435                    Err(e) => {
436                        last_err = Some(e);
437                        if attempt < 2 {
438                            tokio::time::sleep(Duration::from_millis(100 << attempt)).await;
439                        }
440                    }
441                }
442            }
443            let details = match details {
444                Some(d) => d,
445                None => {
446                    return Err(last_err.unwrap_or_else(|| {
447                        A2AError::Http("task poll failed without an error".to_string())
448                    }))
449                }
450            };
451            match details.task.status {
452                TaskStatus::Completed => {
453                    return details.result.ok_or_else(|| {
454                        A2AError::Parse(format!("Task {} completed without a result", task_id))
455                    })
456                }
457                TaskStatus::Failed => {
458                    return Err(A2AError::Api {
459                        code: -32000,
460                        message: details.error.unwrap_or_else(|| "Task failed".to_string()),
461                    })
462                }
463                TaskStatus::Cancelled => {
464                    return Err(A2AError::Api {
465                        code: -32000,
466                        message: format!("Task {} was cancelled", task_id),
467                    })
468                }
469                TaskStatus::Rejected => {
470                    return Err(A2AError::Api {
471                        code: -32000,
472                        message: format!("Task {} was rejected", task_id),
473                    })
474                }
475                TaskStatus::Expired => {
476                    return Err(A2AError::Api {
477                        code: -32000,
478                        message: format!("Task {} expired", task_id),
479                    })
480                }
481                TaskStatus::AuthRequired => {
482                    return Err(A2AError::Api {
483                        code: 401,
484                        message: format!("Task {} requires authentication", task_id),
485                    })
486                }
487                TaskStatus::InputRequired => {
488                    // P2-3: the agent needs more information — surface it so the
489                    // caller can answer via `resume_task` instead of polling forever.
490                    return Err(A2AError::InputRequired {
491                        task_id: task_id.to_string(),
492                        prompt: details
493                            .error
494                            .unwrap_or_else(|| "Input required".to_string()),
495                    });
496                }
497                TaskStatus::Submitted | TaskStatus::Working => {
498                    if start.elapsed() > timeout {
499                        return Err(A2AError::Timeout(format!(
500                            "Task {} did not complete within {:?}",
501                            task_id, timeout
502                        )));
503                    }
504                    tokio::time::sleep(poll_interval).await;
505                }
506            }
507        }
508    }
509
510    /// Send a raw A2A request via POST to the agent endpoint.
511    pub async fn post_request(&self, req: A2ARequest) -> Result<A2AResponse, A2AError> {
512        let url = format!("{}/", self.base_url);
513        let mut request = self.with_traceparent(self.http.post(&url).json(&req));
514        if let Some(token) = &self.auth_token {
515            request = request.bearer_auth(token);
516        }
517        let resp = request.send().await?;
518        let status = resp.status();
519        if !status.is_success() {
520            // 0.22.0 audit fix: servers now surface auth failures as HTTP 401;
521            // prefer the JSON-RPC error body (if any) so API-level errors keep
522            // flowing through non-2xx statuses.
523            if let Ok(a2a_resp) = resp.json::<A2AResponse>().await {
524                if let Some(err) = a2a_resp.error {
525                    return Err(A2AError::from(err));
526                }
527            }
528            return Err(A2AError::Http(format!(
529                "A2A request failed with status {}",
530                status
531            )));
532        }
533        let a2a_resp: A2AResponse = resp
534            .json()
535            .await
536            .map_err(|e| A2AError::Parse(format!("Failed to parse A2A response: {}", e)))?;
537        Ok(a2a_resp)
538    }
539
540    /// Open an SSE stream from `sse_url`, yielding [`TaskPushNotification`](crate::protocol::TaskPushNotification)
541    /// events as they arrive (P2-1).
542    ///
543    /// The stream is useful for observing task progress without polling
544    /// `tasks/get`. Events carry a `task.id`, so a caller receiving
545    /// notifications for multiple tasks can filter by the id it cares about.
546    pub async fn connect_sse(&self, sse_url: &str) -> Result<A2ASseStream, A2AError> {
547        // 0.22.0 audit fix (H-P4): use the streaming client (no total timeout)
548        // so a long-lived SSE stream is not terminated by the RPC client's
549        // 30s whole-body timeout.
550        let mut request = self.with_traceparent(self.stream_http.get(sse_url));
551        if let Some(token) = &self.auth_token {
552            request = request.bearer_auth(token);
553        }
554        let resp = request.send().await?;
555        let status = resp.status();
556        if !status.is_success() {
557            return Err(A2AError::Http(format!(
558                "SSE request failed with status {}",
559                status
560            )));
561        }
562        Ok(A2ASseStream::new(resp))
563    }
564
565    /// Send a task and stream its progress notifications over SSE (P2-1).
566    ///
567    /// Opens the SSE subscription at `sse_url` first (so no early events are
568    /// missed), then sends the task via `tasks/send`. The returned stream
569    /// yields [`TaskPushNotification`](crate::protocol::TaskPushNotification) events for the task.
570    pub async fn send_task_streaming(
571        &self,
572        sse_url: &str,
573        message: A2AMessage,
574    ) -> Result<A2ASseStream, A2AError> {
575        let stream = self.connect_sse(sse_url).await?;
576        let _ = self.send_task(message).await?;
577        Ok(stream)
578    }
579}
580
581/// Builder for [`A2AClient`] with explicit timeouts, TLS enforcement, and auth.
582pub struct A2AClientBuilder {
583    base_url: String,
584    http_client: Option<reqwest::Client>,
585    bearer_token: Option<String>,
586    enforce_https: bool,
587    timeout: Duration,
588    connect_timeout: Duration,
589    trace_id: Option<String>,
590    trace_context: Option<TraceContext>,
591    card_secret: Option<Vec<u8>>,
592    require_card_signature: bool,
593}
594
595impl A2AClientBuilder {
596    /// Start a builder for the given base URL.
597    pub fn new(base_url: impl Into<String>) -> Self {
598        Self {
599            base_url: base_url.into().trim_end_matches('/').to_string(),
600            http_client: None,
601            bearer_token: None,
602            enforce_https: false,
603            timeout: Duration::from_secs(30),
604            connect_timeout: Duration::from_secs(10),
605            trace_id: None,
606            trace_context: None,
607            card_secret: None,
608            require_card_signature: false,
609        }
610    }
611
612    /// Use a custom `reqwest::Client` (overrides the timeouts configured below).
613    pub fn http_client(mut self, client: reqwest::Client) -> Self {
614        self.http_client = Some(client);
615        self
616    }
617
618    /// Send an `Authorization: Bearer <token>` header on every request.
619    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
620        self.bearer_token = Some(token.into());
621        self
622    }
623
624    /// Reject non-HTTPS base URLs at build time (default: off, warn only).
625    pub fn enforce_https(mut self, enforce: bool) -> Self {
626        self.enforce_https = enforce;
627        self
628    }
629
630    /// Per-request timeout (default 30s).
631    pub fn timeout(mut self, timeout: Duration) -> Self {
632        self.timeout = timeout;
633        self
634    }
635
636    /// Connect timeout (default 10s).
637    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
638        self.connect_timeout = timeout;
639        self
640    }
641
642    /// Attach a distributed `trace_id` to every request's metadata (P1-5).
643    pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
644        self.trace_id = Some(trace_id.into());
645        self
646    }
647
648    /// Attach a W3C trace context (P2-8).
649    ///
650    /// Every request carries the context as a standard `traceparent` header,
651    /// and the context's trace id is also attached to request metadata (P1-5)
652    /// so a single configuration populates both channels.
653    pub fn with_traceparent(mut self, context: TraceContext) -> Self {
654        self.trace_context = Some(context.clone());
655        self.trace_id = Some(context.trace_id.clone());
656        self
657    }
658
659    /// Configure a shared HMAC secret used to verify agent-card signatures
660    /// (P1-3).
661    pub fn card_verification_secret(mut self, secret: impl Into<Vec<u8>>) -> Self {
662        self.card_secret = Some(secret.into());
663        self
664    }
665
666    /// Reject signed agent cards that cannot be verified (P1-3).
667    ///
668    /// When enabled, a card carrying a `signature` is refused unless the
669    /// configured [`Self::card_verification_secret`] verifies it. Defaults to
670    /// `false` (signed cards are logged, not rejected, when no secret is set).
671    pub fn require_card_signature(mut self, require: bool) -> Self {
672        self.require_card_signature = require;
673        self
674    }
675
676    /// Build the client, enforcing HTTPS when configured.
677    pub fn build(self) -> Result<A2AClient, A2AError> {
678        if !self.base_url.starts_with("https://") {
679            if self.enforce_https {
680                return Err(A2AError::Http(format!(
681                    "HTTPS is required for A2A, got insecure URL: {}",
682                    self.base_url
683                )));
684            }
685            log::warn!(
686                "A2A client connecting over non-HTTPS URL: {} (use TLS in production)",
687                self.base_url
688            );
689        }
690        let (http, stream_http) = match self.http_client {
691            Some(client) => (client.clone(), client),
692            None => {
693                let http = reqwest::Client::builder()
694                    .timeout(self.timeout)
695                    .connect_timeout(self.connect_timeout)
696                    .build()
697                    .map_err(|e| A2AError::Http(format!("failed to build HTTP client: {}", e)))?;
698                // 0.22.0 audit fix (H-P4): a separate timeout-free client for
699                // SSE streams, mirroring `A2AClient::new`.
700                let stream_http = reqwest::Client::builder()
701                    .connect_timeout(self.connect_timeout)
702                    .build()
703                    .unwrap_or_else(|_| http.clone());
704                (http, stream_http)
705            }
706        };
707        Ok(A2AClient {
708            base_url: self.base_url,
709            http,
710            stream_http,
711            next_id: AtomicU64::new(1),
712            auth_token: self.bearer_token,
713            trace_id: self.trace_id,
714            trace_context: self.trace_context,
715            card_secret: self.card_secret,
716            require_card_signature: self.require_card_signature,
717        })
718    }
719}
720
721#[cfg(test)]
722mod tests;