use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use uuid::Uuid;
use a2a_protocol_types::{JsonRpcRequest, JsonRpcResponse};
use crate::error::{ClientError, ClientResult};
use crate::streaming::EventStream;
use crate::transport::Transport;
enum PendingRequest {
Unary(oneshot::Sender<Result<String, ClientError>>),
Streaming(mpsc::Sender<crate::streaming::event_stream::BodyChunk>),
}
type PendingMap = Mutex<HashMap<String, PendingRequest>>;
fn lock_pending(
pending: &PendingMap,
) -> std::sync::MutexGuard<'_, HashMap<String, PendingRequest>> {
pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct PendingGuard {
pending: Arc<PendingMap>,
request_id: String,
}
impl PendingGuard {
fn register(
pending: &Arc<PendingMap>,
max_pending: usize,
request_id: String,
request: PendingRequest,
) -> ClientResult<Self> {
let mut map = lock_pending(pending);
if map.len() >= max_pending {
return Err(ClientError::TooManyPendingRequests { limit: max_pending });
}
map.insert(request_id.clone(), request);
drop(map);
Ok(Self {
pending: Arc::clone(pending),
request_id,
})
}
}
impl Drop for PendingGuard {
fn drop(&mut self) {
lock_pending(&self.pending).remove(&self.request_id);
}
}
struct WriteCommand {
text: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WebSocketTransportConfig {
pub request_timeout: Duration,
pub extra_headers: HashMap<String, String>,
pub connect_timeout: Duration,
pub max_message_size: usize,
pub max_pending_requests: usize,
}
impl Default for WebSocketTransportConfig {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
extra_headers: HashMap::new(),
max_message_size: crate::transport::DEFAULT_MAX_RESPONSE_SIZE,
max_pending_requests: DEFAULT_MAX_PENDING_REQUESTS,
}
}
}
pub const DEFAULT_MAX_PENDING_REQUESTS: usize = 64;
impl WebSocketTransportConfig {
#[must_use]
pub const fn with_max_pending_requests(mut self, max: usize) -> Self {
self.max_pending_requests = max;
self
}
#[must_use]
pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
#[must_use]
pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
#[must_use]
pub fn with_extra_headers(mut self, headers: HashMap<String, String>) -> Self {
self.extra_headers = headers;
self
}
#[must_use]
pub const fn with_max_message_size(mut self, max_bytes: usize) -> Self {
self.max_message_size = max_bytes;
self
}
}
pub struct WebSocketTransport {
endpoint: String,
config: WebSocketTransportConfig,
current: std::sync::Mutex<Arc<Inner>>,
reconnecting: std::panic::AssertUnwindSafe<tokio::sync::Mutex<()>>,
}
struct Inner {
write_tx: mpsc::Sender<WriteCommand>,
pending: Arc<PendingMap>,
closed: Arc<AtomicBool>,
request_timeout: Duration,
max_pending_requests: usize,
reader_handle: tokio::task::JoinHandle<()>,
writer_handle: tokio::task::JoinHandle<()>,
trace_drop_warned: AtomicBool,
}
impl Drop for Inner {
fn drop(&mut self) {
self.reader_handle.abort();
self.writer_handle.abort();
}
}
impl WebSocketTransport {
pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
Self::connect_with_options(endpoint, Duration::from_secs(30), &HashMap::new()).await
}
pub async fn connect_with_timeout(
endpoint: impl Into<String>,
request_timeout: Duration,
) -> ClientResult<Self> {
Self::connect_with_options(endpoint, request_timeout, &HashMap::new()).await
}
pub async fn connect_with_options(
endpoint: impl Into<String>,
request_timeout: Duration,
extra_headers: &HashMap<String, String>,
) -> ClientResult<Self> {
Self::connect_with_config(
endpoint,
WebSocketTransportConfig::default()
.with_request_timeout(request_timeout)
.with_extra_headers(extra_headers.clone()),
)
.await
}
pub async fn connect_with_config(
endpoint: impl Into<String>,
config: WebSocketTransportConfig,
) -> ClientResult<Self> {
let endpoint = endpoint.into();
validate_ws_url(&endpoint)?;
let first = open(&endpoint, &config).await?;
Ok(Self {
endpoint,
config,
current: std::sync::Mutex::new(Arc::new(first)),
reconnecting: std::panic::AssertUnwindSafe(tokio::sync::Mutex::new(())),
})
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
fn current(&self) -> Arc<Inner> {
Arc::clone(
&self
.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
}
async fn connection(&self) -> ClientResult<Arc<Inner>> {
let inner = self.current();
if !inner.closed.load(Ordering::Acquire) {
return Ok(inner);
}
let _one_at_a_time = self.reconnecting.lock().await;
let inner = self.current();
if !inner.closed.load(Ordering::Acquire) {
return Ok(inner);
}
trace_info!(endpoint = %self.endpoint, "WebSocket connection closed; reconnecting");
let fresh = Arc::new(open(&self.endpoint, &self.config).await?);
*self
.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::clone(&fresh);
Ok(fresh)
}
}
#[allow(clippy::too_many_lines)]
async fn open(endpoint: &str, config: &WebSocketTransportConfig) -> ClientResult<Inner> {
let endpoint = endpoint.to_owned();
{
let mut ws_request = endpoint
.as_str()
.into_client_request()
.map_err(|e| ClientError::Transport(format!("WebSocket request build failed: {e}")))?;
ws_request.headers_mut().insert(
a2a_protocol_types::A2A_VERSION_HEADER,
tokio_tungstenite::tungstenite::http::HeaderValue::from_static(
a2a_protocol_types::A2A_VERSION,
),
);
for (k, v) in &config.extra_headers {
let name = k
.parse::<tokio_tungstenite::tungstenite::http::HeaderName>()
.map_err(|e| {
ClientError::Transport(format!("invalid WebSocket header name {k:?}: {e}"))
})?;
let val = v
.parse::<tokio_tungstenite::tungstenite::http::HeaderValue>()
.map_err(|_| {
ClientError::Transport(format!("invalid WebSocket header value for {k:?}"))
})?;
ws_request.headers_mut().insert(name, val);
}
let ws_config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.max_message_size(Some(config.max_message_size))
.max_frame_size(Some(config.max_message_size));
let (ws_stream, _resp) = tokio::time::timeout(
config.connect_timeout,
tokio_tungstenite::connect_async_with_config(ws_request, Some(ws_config), true),
)
.await
.map_err(|_| {
ClientError::Transport(format!(
"WebSocket connect to {endpoint} did not complete within {:?}",
config.connect_timeout
))
})?
.map_err(connect_error)?;
let (ws_writer, ws_reader) = ws_stream.split();
let pending: Arc<PendingMap> = Arc::new(Mutex::new(HashMap::new()));
let closed = Arc::new(AtomicBool::new(false));
let (write_tx, mut write_rx) = mpsc::channel::<WriteCommand>(64);
let pending_for_writer = Arc::clone(&pending);
let closed_for_writer = Arc::clone(&closed);
let writer_handle = tokio::spawn(async move {
let mut ws_writer = ws_writer;
while let Some(cmd) = write_rx.recv().await {
if ws_writer
.send(WsMessage::Text(cmd.text.into()))
.await
.is_err()
{
fail_all_pending(&pending_for_writer, &closed_for_writer, None);
break;
}
}
});
let pending_for_reader = Arc::clone(&pending);
let closed_for_reader = Arc::clone(&closed);
let reader_handle = tokio::spawn(async move {
let mut ws_reader = ws_reader;
let refused = loop {
match ws_reader.next().await {
Some(Ok(WsMessage::Text(text))) => {
route_frame(&pending_for_reader, text.as_str());
}
Some(Ok(WsMessage::Close(_))) | None => break None,
Some(Err(e)) => break refusal(&e),
Some(Ok(_)) => {}
}
};
fail_all_pending(&pending_for_reader, &closed_for_reader, refused.as_deref());
});
Ok(Inner {
write_tx,
pending,
closed,
request_timeout: config.request_timeout,
max_pending_requests: config.max_pending_requests,
reader_handle,
writer_handle,
trace_drop_warned: AtomicBool::new(false),
})
}
}
impl WebSocketTransport {
async fn execute_request(
&self,
method: &str,
params: serde_json::Value,
extra_headers: &HashMap<String, String>,
) -> ClientResult<serde_json::Value> {
let inner = self.connection().await?;
warn_dropped_per_request_headers(method, extra_headers, &inner.trace_drop_warned);
trace_info!(method, endpoint = %self.endpoint, "sending WebSocket JSON-RPC request");
let rpc_req = build_rpc_request(method, params);
let request_id = rpc_req
.id
.as_value()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let body = serde_json::to_string(&rpc_req).map_err(ClientError::Serialization)?;
let (tx, rx) = oneshot::channel();
let _entry = PendingGuard::register(
&inner.pending,
inner.max_pending_requests,
request_id.clone(),
PendingRequest::Unary(tx),
)?;
inner
.write_tx
.send(WriteCommand { text: body })
.await
.map_err(|_| ClientError::Transport("WebSocket writer task closed".into()))?;
let response_text = match tokio::time::timeout(inner.request_timeout, rx).await {
Ok(received) => received
.map_err(|_| ClientError::Transport("WebSocket reader task closed".into()))??,
Err(_elapsed) => {
return Err(ClientError::Timeout("WebSocket response timed out".into()));
}
};
let envelope: JsonRpcResponse<serde_json::Value> =
serde_json::from_str(&response_text).map_err(ClientError::Serialization)?;
match envelope {
JsonRpcResponse::Success(ok) => {
trace_info!(method, "WebSocket request succeeded");
Ok(ok.result)
}
JsonRpcResponse::Error(err) => {
trace_warn!(
method,
code = err.error.code,
"JSON-RPC error over WebSocket"
);
let a2a = crate::transport::map_jsonrpc_error(
err.error.code,
err.error.message,
err.error.data,
);
Err(ClientError::Protocol(a2a))
}
}
}
async fn execute_streaming_request(
&self,
method: &str,
params: serde_json::Value,
extra_headers: &HashMap<String, String>,
) -> ClientResult<EventStream> {
let inner = self.connection().await?;
warn_dropped_per_request_headers(method, extra_headers, &inner.trace_drop_warned);
trace_info!(method, endpoint = %self.endpoint, "opening WebSocket stream");
let rpc_req = build_rpc_request(method, params);
let request_id = rpc_req
.id
.as_value()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let body = serde_json::to_string(&rpc_req).map_err(ClientError::Serialization)?;
let (tx, rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(64);
let entry = PendingGuard::register(
&inner.pending,
inner.max_pending_requests,
request_id,
PendingRequest::Streaming(tx),
)?;
inner
.write_tx
.send(WriteCommand { text: body })
.await
.map_err(|_| ClientError::Transport("WebSocket writer task closed".into()))?;
Ok(EventStream::new(rx)
.with_first_event_timeout(inner.request_timeout)
.holding((entry, inner)))
}
}
impl Transport for WebSocketTransport {
fn send_request<'a>(
&'a self,
method: &'a str,
params: serde_json::Value,
extra_headers: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
Box::pin(self.execute_request(method, params, extra_headers))
}
fn send_streaming_request<'a>(
&'a self,
method: &'a str,
params: serde_json::Value,
extra_headers: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
Box::pin(self.execute_streaming_request(method, params, extra_headers))
}
}
impl std::fmt::Debug for WebSocketTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebSocketTransport")
.field("endpoint", &self.endpoint)
.finish_non_exhaustive()
}
}
const TRACE_HEADERS: [&str; 2] = [
a2a_protocol_types::trace_context::TRACEPARENT_HEADER,
a2a_protocol_types::trace_context::TRACESTATE_HEADER,
];
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_dropped_per_request_headers(
method: &str,
extra_headers: &HashMap<String, String>,
trace_warned: &AtomicBool,
) {
let trace_dropped = extra_headers
.keys()
.any(|k| TRACE_HEADERS.iter().any(|t| k.eq_ignore_ascii_case(t)));
let other_dropped = extra_headers
.keys()
.filter(|k| !TRACE_HEADERS.iter().any(|t| k.eq_ignore_ascii_case(t)))
.count();
if other_dropped > 0 {
trace_warn!(
method,
header_count = other_dropped,
"per-request headers are not sent over an established WebSocket connection; \
supply credentials at connect time via WebSocketTransport::connect_with_options"
);
}
if trace_dropped && !trace_warned.swap(true, Ordering::Relaxed) {
trace_warn!(
method,
"W3C trace context cannot be propagated per request over an established \
WebSocket connection: the headers ride the HTTP upgrade only, so one value \
would label every request on this connection. Traced calls belong on a \
canonical A2A transport (JSON-RPC, REST or gRPC). Reported once per connection"
);
}
}
fn refusal(e: &tokio_tungstenite::tungstenite::Error) -> Option<String> {
use tokio_tungstenite::tungstenite::Error;
match e {
Error::Io(_)
| Error::ConnectionClosed
| Error::AlreadyClosed
| Error::Protocol(
tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
) => None,
other => Some(format!("WebSocket frame refused: {other}")),
}
}
fn fail_all_pending(pending: &PendingMap, closed: &AtomicBool, refused: Option<&str>) {
closed.store(true, Ordering::Release);
let entries: Vec<PendingRequest> = lock_pending(pending).drain().map(|(_, v)| v).collect();
for entry in entries {
match entry {
PendingRequest::Unary(tx) if refused.is_some() => {
let _ = tx.send(Err(ClientError::Transport(
refused.unwrap_or_default().to_owned(),
)));
}
PendingRequest::Streaming(tx) if refused.is_some() => {
let _ = tx.try_send(Err(ClientError::Transport(
refused.unwrap_or_default().to_owned(),
)));
}
PendingRequest::Unary(tx) => {
let _ = tx.send(Err(ClientError::HttpClient(
"WebSocket connection closed".into(),
)));
}
PendingRequest::Streaming(tx) => drop(tx),
}
}
}
fn connect_error(e: tokio_tungstenite::tungstenite::Error) -> ClientError {
use tokio_tungstenite::tungstenite::Error;
match e {
Error::Http(resp) => ClientError::UnexpectedStatus {
status: resp.status().as_u16(),
body: String::from_utf8_lossy(resp.body().as_deref().unwrap_or_default()).into_owned(),
retry_after: None,
},
Error::Io(io) => ClientError::HttpClient(format!("WebSocket connect failed: {io}")),
other => ClientError::Transport(format!("WebSocket connect failed: {other}")),
}
}
fn route_frame(pending: &PendingMap, text: &str) {
let Some(request_id) = extract_jsonrpc_id(text) else {
return;
};
let streaming_tx = {
let mut map = lock_pending(pending);
let tx = match map.get(&request_id) {
Some(PendingRequest::Unary(_)) => {
if let Some(PendingRequest::Unary(tx)) = map.remove(&request_id) {
let _ = tx.send(Ok(text.to_owned()));
}
return;
}
Some(PendingRequest::Streaming(tx)) => tx.clone(),
None => return,
};
drop(map);
tx
};
if is_stream_complete_sentinel(text) {
lock_pending(pending).remove(&request_id);
return;
}
if streaming_tx.capacity() <= 1 {
let _ = streaming_tx.try_send(Err(ClientError::Protocol(
a2a_protocol_types::error::A2aError::stream_lagged(1),
)));
lock_pending(pending).remove(&request_id);
return;
}
let sse_line = format!("data: {text}\n\n");
if streaming_tx
.try_send(Ok(hyper::body::Bytes::from(sse_line)))
.is_err()
{
lock_pending(pending).remove(&request_id);
return;
}
if is_stream_terminal(text) {
lock_pending(pending).remove(&request_id);
}
}
pub(crate) fn extract_jsonrpc_id(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
match v.get("id") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Number(n)) => Some(n.to_string()),
_ => None,
}
}
fn task_state_str_is_terminal(state: &str) -> bool {
serde_json::from_value::<a2a_protocol_types::TaskState>(serde_json::Value::String(
state.to_owned(),
))
.is_ok_and(a2a_protocol_types::TaskState::is_terminal)
}
fn is_stream_complete_sentinel(text: &str) -> bool {
let Ok(frame) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
let Some(r) = frame.get("result") else {
return false;
};
r.get("stream_complete").is_some()
|| r.get("status").and_then(|s| s.as_str()) == Some("stream_complete")
}
fn is_stream_terminal(text: &str) -> bool {
let Ok(frame) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
let has_terminal_state = |obj: &serde_json::Value| -> bool {
if let Some(status_update) = obj.get("statusUpdate")
&& let Some(status) = status_update.get("status")
&& let Some(state) = status.get("state").and_then(|s| s.as_str())
{
return task_state_str_is_terminal(state);
}
if let Some(status) = obj.get("status")
&& let Some(state) = status.get("state").and_then(|s| s.as_str())
{
return task_state_str_is_terminal(state);
}
false
};
if let Some(r) = frame.get("result") {
if r.get("stream_complete").is_some() {
return true;
}
if r.get("status").and_then(|s| s.as_str()) == Some("stream_complete") {
return true;
}
return has_terminal_state(r);
}
has_terminal_state(&frame)
}
fn build_rpc_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
let id = serde_json::Value::String(Uuid::new_v4().to_string());
JsonRpcRequest::with_params(id, method, params)
}
fn validate_ws_url(url: &str) -> ClientResult<()> {
if url.is_empty() {
return Err(ClientError::InvalidEndpoint("URL must not be empty".into()));
}
if !url.starts_with("ws://") && !url.starts_with("wss://") {
return Err(ClientError::InvalidEndpoint(format!(
"WebSocket URL must start with ws:// or wss://: {url}"
)));
}
Ok(())
}
#[cfg(test)]
const _: () = {
const fn assert_auto_traits<
T: Send + Sync + Unpin + std::panic::UnwindSafe + std::panic::RefUnwindSafe,
>() {
}
assert_auto_traits::<WebSocketTransport>();
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_ws_url_rejects_empty() {
assert!(validate_ws_url("").is_err());
}
#[tokio::test]
async fn invalid_extra_header_name_fails_the_connect_closed() {
let mut headers = HashMap::new();
headers.insert("not a header name".to_owned(), "x".to_owned());
let err = WebSocketTransport::connect_with_options(
"ws://127.0.0.1:1/ws",
Duration::from_secs(1),
&headers,
)
.await
.expect_err("an invalid header name must fail the connect");
let msg = err.to_string();
assert!(
msg.contains("invalid WebSocket header name"),
"error must name the problem: {msg}"
);
}
#[tokio::test]
async fn invalid_extra_header_value_fails_the_connect_without_echoing_it() {
let mut headers = HashMap::new();
headers.insert("authorization".to_owned(), "Bearer sec\u{0}ret".to_owned());
let err = WebSocketTransport::connect_with_options(
"ws://127.0.0.1:1/ws",
Duration::from_secs(1),
&headers,
)
.await
.expect_err("an invalid header value must fail the connect");
let msg = err.to_string();
assert!(
msg.contains("invalid WebSocket header value for \"authorization\""),
"error must name the header: {msg}"
);
assert!(!msg.contains("sec"), "the value must not be echoed: {msg}");
}
#[test]
fn with_extra_headers_sets_the_headers() {
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer tok".to_string());
headers.insert("x-custom".to_string(), "v".to_string());
let config = WebSocketTransportConfig::default().with_extra_headers(headers.clone());
assert_eq!(config.extra_headers, headers);
assert_eq!(
config
.extra_headers
.get("authorization")
.map(String::as_str),
Some("Bearer tok")
);
}
#[test]
fn validate_ws_url_rejects_http() {
assert!(validate_ws_url("http://localhost:8080").is_err());
}
#[test]
fn validate_ws_url_accepts_ws() {
assert!(validate_ws_url("ws://localhost:8080").is_ok());
}
#[test]
fn validate_ws_url_accepts_wss() {
assert!(validate_ws_url("wss://agent.example.com/a2a").is_ok());
}
#[test]
fn is_stream_terminal_completed_status() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"completed"}}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_failed_status() {
let frame =
r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"failed"}}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_working_is_not_terminal() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"working"}}}}"#;
assert!(!is_stream_terminal(frame));
}
#[test]
fn stream_complete_sentinel_is_recognized_in_both_spellings() {
assert!(is_stream_complete_sentinel(
r#"{"jsonrpc":"2.0","id":"1","result":{"status":"stream_complete"}}"#
));
assert!(is_stream_complete_sentinel(
r#"{"jsonrpc":"2.0","id":"1","result":{"stream_complete":true}}"#
));
}
#[test]
fn real_events_are_not_mistaken_for_the_sentinel() {
for frame in [
r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"TASK_STATE_COMPLETED"}}}}"#,
r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"TASK_STATE_INPUT_REQUIRED"}}}}"#,
r#"{"jsonrpc":"2.0","id":"1","result":{"task":{"id":"t1"}}}"#,
r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"taskId":"t1"}}}"#,
r#"{"jsonrpc":"2.0","id":"1","result":{"task":{"id":"stream_complete"}}}"#,
] {
assert!(
!is_stream_complete_sentinel(frame),
"wrongly treated as the end-of-stream sentinel: {frame}"
);
}
}
#[test]
fn the_sentinel_cannot_deserialize_as_a_stream_response() {
let result = serde_json::from_str::<a2a_protocol_types::events::StreamResponse>(
r#"{"status":"stream_complete"}"#,
);
let err = result.expect_err("the sentinel must not parse as a StreamResponse");
assert!(
err.to_string().contains("unknown variant"),
"expected an unknown-variant error, got: {err}"
);
}
#[test]
fn is_stream_terminal_stream_complete_sentinel() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"stream_complete":true}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_artifact_not_terminal() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[]}}}}"#;
assert!(!is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_payload_containing_word_not_terminal() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[{"text":"task completed successfully"}]}}}}"#;
assert!(!is_stream_terminal(frame));
}
#[test]
fn build_rpc_request_has_method() {
let req = build_rpc_request("TestMethod", serde_json::json!({"key": "val"}));
assert_eq!(req.method, "TestMethod");
let params = req.params.expect("params should be present");
assert_eq!(params["key"], "val");
let id = req.id.as_value().expect("id should be present");
assert!(id.is_string(), "id should be a string UUID");
assert!(!id.as_str().unwrap().is_empty(), "id should not be empty");
}
#[test]
fn is_stream_terminal_invalid_json() {
assert!(!is_stream_terminal("not json"));
}
#[test]
fn is_stream_terminal_no_result() {
assert!(!is_stream_terminal(r#"{"jsonrpc":"2.0","id":"1"}"#));
}
#[test]
fn is_stream_terminal_task_level_completed() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"completed"}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_canceled() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"canceled"}}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_rejected() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"rejected"}}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_task_level_failed() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"failed"}}}"#;
assert!(is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_non_string_state() {
let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":42}}}"#;
assert!(!is_stream_terminal(frame));
}
#[test]
fn is_stream_terminal_canonical_screaming_snake_case() {
for state in [
"TASK_STATE_COMPLETED",
"TASK_STATE_FAILED",
"TASK_STATE_CANCELED",
"TASK_STATE_REJECTED",
] {
let frame = format!(
r#"{{"jsonrpc":"2.0","id":"1","result":{{"statusUpdate":{{"status":{{"state":"{state}"}}}}}}}}"#
);
assert!(
is_stream_terminal(&frame),
"canonical terminal state {state} not detected"
);
}
}
#[test]
fn is_stream_terminal_canonical_non_terminal() {
for state in ["TASK_STATE_WORKING", "TASK_STATE_SUBMITTED", "working"] {
let frame = format!(
r#"{{"jsonrpc":"2.0","id":"1","result":{{"status":{{"state":"{state}"}}}}}}"#
);
assert!(
!is_stream_terminal(&frame),
"non-terminal state {state} wrongly detected as terminal"
);
}
}
#[test]
fn validate_ws_url_rejects_https() {
assert!(validate_ws_url("https://example.com").is_err());
}
#[test]
fn validate_ws_url_error_message_contains_url() {
let err = validate_ws_url("http://bad").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("http://bad") || msg.contains("ws://"));
}
#[test]
fn extract_jsonrpc_id_string() {
let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":"abc","result":{}}"#);
assert_eq!(id.as_deref(), Some("abc"));
}
#[test]
fn extract_jsonrpc_id_number() {
let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":42,"result":{}}"#);
assert_eq!(id.as_deref(), Some("42"));
}
#[test]
fn extract_jsonrpc_id_null_returns_none() {
let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":null,"result":{}}"#);
assert!(id.is_none());
}
#[test]
fn extract_jsonrpc_id_missing_returns_none() {
let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","result":{}}"#);
assert!(id.is_none());
}
async fn silent_server() -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
std::future::pending::<()>().await;
drop(sock);
}
});
addr
}
#[tokio::test]
async fn connect_gives_up_on_a_server_that_never_completes_the_handshake() {
let addr = silent_server().await;
let started = std::time::Instant::now();
let err = WebSocketTransport::connect_with_config(
format!("ws://{addr}"),
WebSocketTransportConfig::default().with_connect_timeout(Duration::from_millis(300)),
)
.await
.expect_err("a silent server must not yield a transport");
assert!(
err.to_string().contains("did not complete within"),
"expected a connect-deadline error, got: {err}"
);
assert!(
started.elapsed() < Duration::from_secs(2),
"the deadline was 300ms; returning after {:?} means it did not apply",
started.elapsed()
);
}
#[tokio::test]
async fn the_connect_deadline_defaults_to_ten_seconds() {
assert_eq!(
WebSocketTransportConfig::default().connect_timeout,
Duration::from_secs(10)
);
}
#[tokio::test]
async fn timed_out_request_is_removed_from_pending_map() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
return;
};
while let Some(Ok(_)) = ws.next().await {}
});
}
});
let transport = WebSocketTransport::connect_with_timeout(
format!("ws://{addr}"),
Duration::from_millis(100),
)
.await
.expect("connect");
let started = std::time::Instant::now();
let err = transport
.send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
.await
.expect_err("request must time out");
assert!(
started.elapsed() < Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
assert!(
matches!(err, ClientError::Timeout(_)),
"expected timeout, got: {err:?}"
);
assert!(
lock_pending(&transport.current().pending).is_empty(),
"pending map must not retain timed-out requests"
);
}
#[tokio::test]
async fn a_cancelled_request_does_not_leak_its_pending_entry() {
let addr = spawn_silent_ws_server().await;
let transport = WebSocketTransport::connect_with_timeout(
format!("ws://{addr}"),
Duration::from_secs(30),
)
.await
.expect("connect");
for i in 0..5 {
let outcome = tokio::time::timeout(
Duration::from_millis(80),
transport.send_request(
"GetTask",
serde_json::json!({ "id": format!("t{i}") }),
&HashMap::new(),
),
)
.await;
assert!(
outcome.is_err(),
"the server never answers, so it must elapse"
);
}
tokio::time::sleep(Duration::from_millis(100)).await;
let leaked = lock_pending(&transport.current().pending).len();
assert_eq!(
leaked, 0,
"5 cancelled requests left {leaked} pending entries"
);
}
#[tokio::test]
async fn an_abandoned_stream_does_not_leak_its_pending_entry() {
let addr = spawn_silent_ws_server().await;
let transport = WebSocketTransport::connect_with_timeout(
format!("ws://{addr}"),
Duration::from_secs(30),
)
.await
.expect("connect");
for i in 0..5 {
let stream = transport
.send_streaming_request(
"SendStreamingMessage",
serde_json::json!({ "id": format!("s{i}") }),
&HashMap::new(),
)
.await
.expect("the stream is established even though nothing answers");
drop(stream);
}
tokio::time::sleep(Duration::from_millis(100)).await;
let leaked = lock_pending(&transport.current().pending).len();
assert_eq!(
leaked, 0,
"5 abandoned streams left {leaked} pending entries"
);
}
#[tokio::test]
async fn a_live_stream_keeps_its_pending_entry() {
let addr = spawn_silent_ws_server().await;
let transport = WebSocketTransport::connect_with_timeout(
format!("ws://{addr}"),
Duration::from_secs(30),
)
.await
.expect("connect");
let stream = transport
.send_streaming_request(
"SendStreamingMessage",
serde_json::json!({ "id": "live" }),
&HashMap::new(),
)
.await
.expect("stream opens");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
lock_pending(&transport.current().pending).len(),
1,
"a stream still held by its consumer must stay routable"
);
drop(stream);
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
lock_pending(&transport.current().pending).len(),
0,
"and must release the entry once the consumer lets go"
);
}
#[tokio::test]
async fn the_pending_map_is_capped_at_max_pending_requests() {
let addr = spawn_silent_ws_server().await;
let transport = WebSocketTransport::connect_with_config(
format!("ws://{addr}"),
WebSocketTransportConfig::default()
.with_request_timeout(Duration::from_millis(200))
.with_max_pending_requests(1),
)
.await
.expect("connect");
let stream = transport
.send_streaming_request(
"SendStreamingMessage",
serde_json::json!({ "id": "holds-the-slot" }),
&HashMap::new(),
)
.await
.expect("the first request takes the only slot");
let started = std::time::Instant::now();
let refused = transport
.send_request("GetTask", serde_json::json!({ "id": "t" }), &HashMap::new())
.await
.expect_err("the second request must be refused, not queued");
assert!(
matches!(refused, ClientError::TooManyPendingRequests { limit: 1 }),
"got: {refused:?}"
);
assert!(
started.elapsed() < Duration::from_millis(100),
"refused up front, not after the request timeout"
);
assert!(refused.is_retryable(), "room appears as responses arrive");
drop(stream);
let after = transport
.send_request("GetTask", serde_json::json!({ "id": "t" }), &HashMap::new())
.await
.expect_err("a silent server answers nothing, so this times out");
assert!(
matches!(after, ClientError::Timeout(_)),
"the slot is free again; the request is sent and waits: {after:?}"
);
}
async fn spawn_silent_ws_server() -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
return;
};
while let Some(Ok(_)) = ws.next().await {}
});
}
});
addr
}
async fn spawn_raw_ws_server<F, Fut>(per_conn: F) -> std::net::SocketAddr
where
F: Fn(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> Fut
+ Send
+ Sync
+ 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let per_conn = Arc::new(per_conn);
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let per_conn = Arc::clone(&per_conn);
tokio::spawn(async move {
if let Ok(ws) = tokio_tungstenite::accept_async(stream).await {
per_conn(ws).await;
}
});
}
});
addr
}
#[tokio::test]
async fn dropping_transport_closes_connection() {
let (closed_tx, mut closed_rx) = mpsc::channel::<()>(1);
let closed_tx = Arc::new(closed_tx);
let addr = spawn_raw_ws_server(move |mut ws| {
let closed_tx = Arc::clone(&closed_tx);
async move {
while let Some(Ok(_)) = ws.next().await {}
let _ = closed_tx.send(()).await;
}
})
.await;
let transport = WebSocketTransport::connect(format!("ws://{addr}"))
.await
.expect("connect");
drop(transport);
tokio::time::timeout(Duration::from_secs(5), closed_rx.recv())
.await
.expect("server must observe the connection closing after drop")
.expect("channel open");
}
#[tokio::test]
async fn a_stream_outlives_the_transport_that_opened_it() {
let (go_tx, go_rx) = tokio::sync::oneshot::channel::<()>();
let go_rx = Arc::new(Mutex::new(Some(go_rx)));
let addr = spawn_raw_ws_server(move |mut ws| {
let go_rx = go_rx.lock().unwrap().take();
async move {
let Some(Ok(WsMessage::Text(req))) = ws.next().await else {
return;
};
let id = serde_json::from_str::<serde_json::Value>(&req).unwrap()["id"].clone();
let event = |state: &str| {
let status = serde_json::json!({ "state": state });
let update = serde_json::json!({ "taskId": "t", "contextId": "c", "status": status });
let frame = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { "statusUpdate": update } });
WsMessage::Text(frame.to_string().into())
};
let _ = ws.send(event("TASK_STATE_WORKING")).await;
if let Some(go) = go_rx {
let _ = go.await;
}
let _ = ws.send(event("TASK_STATE_COMPLETED")).await;
while let Some(Ok(_)) = ws.next().await {}
}
})
.await;
let transport = WebSocketTransport::connect(format!("ws://{addr}"))
.await
.expect("connect");
let mut stream = transport
.send_streaming_request(
"SendStreamingMessage",
serde_json::json!({"message": {}}),
&HashMap::new(),
)
.await
.expect("stream opens");
let first = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("the first event arrives");
assert!(matches!(first, Some(Ok(_))), "first event: {first:?}");
drop(transport);
let _ = go_tx.send(());
let second = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("the stream must not go silent once its transport is dropped");
assert!(matches!(second, Some(Ok(_))), "second event: {second:?}");
let end = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("the stream ends after its final event");
assert!(end.is_none(), "a completed stream ends cleanly: {end:?}");
}
#[tokio::test]
async fn server_close_fails_pending_request_fast() {
let addr = spawn_raw_ws_server(|mut ws| async move {
let _ = ws.next().await;
let _ = ws.close(None).await;
})
.await;
let transport = WebSocketTransport::connect_with_timeout(
format!("ws://{addr}"),
Duration::from_secs(30),
)
.await
.expect("connect");
let start = std::time::Instant::now();
let err = transport
.send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
.await
.expect_err("request must fail when the server closes");
assert!(
matches!(err, ClientError::HttpClient(_)) && err.is_retryable(),
"expected a retryable HttpClient error, got: {err:?}"
);
assert!(
start.elapsed() < Duration::from_secs(10),
"failure must be prompt, took {:?} against a 30s request timeout",
start.elapsed()
);
let start = std::time::Instant::now();
let err = transport
.send_request("GetTask", serde_json::json!({"id": "t2"}), &HashMap::new())
.await
.expect_err("the server cuts the new connection too");
assert!(
err.is_retryable() && start.elapsed() < Duration::from_secs(10),
"expected a prompt retryable error, got {err:?} after {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn oversized_incoming_frame_is_rejected() {
let addr = spawn_raw_ws_server(|mut ws| async move {
if let Some(Ok(_)) = ws.next().await {
let big = "x".repeat(64 * 1024);
let _ = ws
.send(tokio_tungstenite::tungstenite::Message::Text(big.into()))
.await;
}
while let Some(Ok(_)) = ws.next().await {}
})
.await;
let transport = WebSocketTransport::connect_with_config(
format!("ws://{addr}"),
WebSocketTransportConfig::default()
.with_request_timeout(Duration::from_secs(30))
.with_max_message_size(16 * 1024),
)
.await
.expect("connect");
let start = std::time::Instant::now();
let err = transport
.send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
.await
.expect_err("oversized frame must fail the request");
assert!(
matches!(err, ClientError::Transport(_)),
"expected transport error, got: {err:?}"
);
assert!(
start.elapsed() < Duration::from_secs(10),
"rejection must be prompt, took {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn oversized_frame_on_a_stream_is_a_refusal_not_a_cut() {
let addr = spawn_raw_ws_server(|mut ws| async move {
if let Some(Ok(_)) = ws.next().await {
let big = "x".repeat(64 * 1024);
let _ = ws
.send(tokio_tungstenite::tungstenite::Message::Text(big.into()))
.await;
}
while let Some(Ok(_)) = ws.next().await {}
})
.await;
let transport = WebSocketTransport::connect_with_config(
format!("ws://{addr}"),
WebSocketTransportConfig::default().with_max_message_size(16 * 1024),
)
.await
.expect("connect");
let mut stream = transport
.send_streaming_request(
"SendStreamingMessage",
serde_json::json!({"message": {}}),
&HashMap::new(),
)
.await
.expect("stream opens");
let first = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("prompt");
assert!(
matches!(first, Some(Err(ClientError::Transport(ref m))) if m.contains("refused")),
"expected the refusal, got {first:?}"
);
}
#[cfg(feature = "tracing")]
#[test]
fn warn_dropped_per_request_headers_warns_iff_headers_present() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingSubscriber(Arc<AtomicUsize>);
impl tracing::Subscriber for CountingSubscriber {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, _: &tracing::Event<'_>) {
self.0.fetch_add(1, Ordering::SeqCst);
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let count = Arc::new(AtomicUsize::new(0));
let warned = AtomicBool::new(false);
tracing::subscriber::with_default(CountingSubscriber(Arc::clone(&count)), || {
warn_dropped_per_request_headers("SendMessage", &HashMap::new(), &warned);
assert_eq!(
count.load(Ordering::SeqCst),
0,
"must not warn when there are no per-request headers to drop"
);
let mut headers = HashMap::new();
headers.insert("authorization".to_owned(), "Bearer secret".to_owned());
warn_dropped_per_request_headers("SendMessage", &headers, &warned);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"dropping a per-request header must emit a warning (never silent)"
);
warn_dropped_per_request_headers("SendMessage", &headers, &warned);
assert_eq!(
count.load(Ordering::SeqCst),
2,
"each unauthenticated request must be reported"
);
});
}
#[cfg(feature = "tracing")]
#[test]
fn a_dropped_traceparent_is_reported_once_per_connection() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingSubscriber(Arc<AtomicUsize>);
impl tracing::Subscriber for CountingSubscriber {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, _: &tracing::Event<'_>) {
self.0.fetch_add(1, Ordering::SeqCst);
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let count = Arc::new(AtomicUsize::new(0));
let warned = AtomicBool::new(false);
tracing::subscriber::with_default(CountingSubscriber(Arc::clone(&count)), || {
let mut headers = HashMap::new();
headers.insert(
"Traceparent".to_owned(),
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_owned(),
);
headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
for _ in 0..5 {
warn_dropped_per_request_headers("SendMessage", &headers, &warned);
}
assert_eq!(
count.load(Ordering::SeqCst),
1,
"the binding's limitation is stated once, not once per request"
);
headers.insert("authorization".to_owned(), "Bearer secret".to_owned());
warn_dropped_per_request_headers("SendMessage", &headers, &warned);
assert_eq!(
count.load(Ordering::SeqCst),
2,
"a dropped credential is never suppressed by the trace notice"
);
});
}
}