#![cfg(feature = "postgres")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use a2a_protocol_server::rate_limit::{RateLimitConfig, RateLimitInterceptor};
use a2a_protocol_server::store::PostgresTaskStore;
use a2a_protocol_server::{RequestHandler, RequestHandlerBuilder, SendMessageResult};
use a2a_protocol_types::params::{MessageSendParams, TaskIdParams, TaskQueryParams};
use a2a_protocol_types::task::{TaskId, TaskState};
const URL_ENV: &str = "A2A_TEST_POSTGRES_URL";
struct TestDb {
admin_url: String,
name: String,
}
impl TestDb {
async fn create(tag: &str) -> Self {
let admin_url = std::env::var(URL_ENV)
.unwrap_or_else(|_| panic!("{URL_ENV} must be set for the multi-replica suite"));
let name = format!("a2a_replica_{tag}");
let pool = sqlx::postgres::PgPool::connect(&admin_url)
.await
.expect("connect to admin database");
let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS {name}"))
.execute(&pool)
.await;
sqlx::query(&format!("CREATE DATABASE {name}"))
.execute(&pool)
.await
.expect("create scratch database");
pool.close().await;
Self { admin_url, name }
}
fn url(&self) -> String {
let base = self.admin_url.rsplit_once('/').expect("url has a path").0;
format!("{base}/{}", self.name)
}
async fn drop_db(self) {
if let Ok(pool) = sqlx::postgres::PgPool::connect(&self.admin_url).await {
let _ = sqlx::query(&format!(
"DROP DATABASE IF EXISTS \"{}\" WITH (FORCE)",
self.name
))
.execute(&pool)
.await;
pool.close().await;
}
}
}
struct StreamingExec {
step: Duration,
}
impl a2a_protocol_server::executor::AgentExecutor for StreamingExec {
fn execute<'a>(
&'a self,
ctx: &'a a2a_protocol_server::request_context::RequestContext,
queue: &'a dyn a2a_protocol_server::streaming::EventQueueWriter,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>,
> {
let step = self.step;
Box::pin(async move {
use a2a_protocol_server::executor_helpers::EventEmitter;
let emitter = EventEmitter::new(ctx, queue);
emitter.status(TaskState::Working).await?;
tokio::time::sleep(step).await;
emitter
.artifact(
"out",
vec![a2a_protocol_types::Part::text("from replica A")],
Some(true),
Some(true),
)
.await?;
tokio::time::sleep(step).await;
emitter.status(TaskState::Completed).await?;
Ok(())
})
}
}
async fn replica(url: &str, step: Duration) -> Arc<RequestHandler> {
let store = PostgresTaskStore::new(url)
.await
.expect("postgres task store");
Arc::new(
RequestHandlerBuilder::new(StreamingExec { step })
.with_task_store_arc(Arc::new(store))
.build()
.expect("handler builds"),
)
}
fn message(id: &str) -> MessageSendParams {
serde_json::from_value(serde_json::json!({
"message": {
"messageId": id,
"role": "ROLE_USER",
"parts": [{"text": "hello"}]
}
}))
.expect("params parse")
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn a_task_created_on_one_replica_is_readable_on_the_other() {
let db = TestDb::create("visibility").await;
let a = replica(&db.url(), Duration::from_millis(10)).await;
let b = replica(&db.url(), Duration::from_millis(10)).await;
let result = a
.on_send_message(message("m-visible"), false, None)
.await
.expect("replica A accepts the message");
let task_id = match result {
SendMessageResult::Response(response) => match response {
a2a_protocol_types::responses::SendMessageResponse::Task(t) => t.id,
other => panic!("expected a task, got {other:?}"),
},
SendMessageResult::Stream(_) => panic!("blocking send returned a stream"),
};
let seen_by_b = b
.on_get_task(
TaskQueryParams {
id: task_id.0.clone(),
tenant: None,
history_length: None,
},
None,
)
.await
.expect("replica B can read a task replica A created");
assert_eq!(seen_by_b.id, task_id);
let _ = a.shutdown().await;
let _ = b.shutdown().await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn a_subscriber_on_the_other_replica_still_sees_the_stream_end() {
use a2a_protocol_server::streaming::EventQueueReader as _;
let db = TestDb::create("subscribe").await;
let a = replica(&db.url(), Duration::from_millis(150)).await;
let b = replica(&db.url(), Duration::from_millis(150)).await;
let result = a
.on_send_message(message("m-sub"), true, None)
.await
.expect("replica A accepts the streaming message");
let mut stream_a = match result {
SendMessageResult::Stream(reader) => reader,
SendMessageResult::Response(_) => panic!("streaming send returned a response"),
};
let first = stream_a
.read()
.await
.expect("A produces a first event")
.expect("and it is not an error frame");
let task_id = task_id_of(&first).expect("the first frame names its task");
let mut stream_b = b
.on_resubscribe(
TaskIdParams {
id: task_id.0.clone(),
tenant: None,
},
None,
)
.await
.expect("replica B accepts the subscription");
let drain_a = tokio::spawn(async move { while stream_a.read().await.is_some() {} });
let mut last_state = None;
let ended = tokio::time::timeout(Duration::from_secs(10), async {
while let Some(Ok(event)) = stream_b.read().await {
if let Some(state) = terminal_state_of(&event) {
last_state = Some(state);
}
}
})
.await;
drain_a.await.expect("A's drain joins");
assert!(
ended.is_ok(),
"the subscriber on the other replica never saw the stream end; \
a task completing on replica A must still terminate a subscription on B"
);
assert_eq!(
last_state,
Some(TaskState::Completed),
"the stream ended, but not with the terminal state the task reached"
);
let _ = a.shutdown().await;
let _ = b.shutdown().await;
db.drop_db().await;
}
fn task_id_of(event: &a2a_protocol_types::events::StreamResponse) -> Option<TaskId> {
use a2a_protocol_types::events::StreamResponse;
match event {
StreamResponse::Task(t) => Some(t.id.clone()),
StreamResponse::StatusUpdate(e) => Some(e.task_id.clone()),
StreamResponse::ArtifactUpdate(e) => Some(e.task_id.clone()),
_ => None,
}
}
fn terminal_state_of(event: &a2a_protocol_types::events::StreamResponse) -> Option<TaskState> {
use a2a_protocol_types::events::StreamResponse;
match event {
StreamResponse::StatusUpdate(e) if e.status.state.is_terminal() => Some(e.status.state),
StreamResponse::Task(t) if t.status.state.is_terminal() => Some(t.status.state),
_ => None,
}
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn intermediate_events_do_not_cross_replicas() {
use a2a_protocol_server::streaming::EventQueueReader as _;
use a2a_protocol_types::events::StreamResponse;
let db = TestDb::create("events").await;
let a = replica(&db.url(), Duration::from_millis(150)).await;
let b = replica(&db.url(), Duration::from_millis(150)).await;
let result = a
.on_send_message(message("m-events"), true, None)
.await
.expect("replica A accepts the streaming message");
let mut stream_a = match result {
SendMessageResult::Stream(reader) => reader,
SendMessageResult::Response(_) => panic!("streaming send returned a response"),
};
let first = stream_a
.read()
.await
.expect("A produces a first event")
.expect("and it is not an error frame");
let task_id = task_id_of(&first).expect("the first frame names its task");
let mut stream_b = b
.on_resubscribe(
TaskIdParams {
id: task_id.0,
tenant: None,
},
None,
)
.await
.expect("replica B accepts the subscription");
let a_artifacts = tokio::spawn(async move {
let mut count = 0_usize;
while let Some(Ok(event)) = stream_a.read().await {
if matches!(event, StreamResponse::ArtifactUpdate(_)) {
count += 1;
}
}
count
});
let mut b_artifacts = 0_usize;
let _ = tokio::time::timeout(Duration::from_secs(10), async {
while let Some(Ok(event)) = stream_b.read().await {
if matches!(event, StreamResponse::ArtifactUpdate(_)) {
b_artifacts += 1;
}
}
})
.await;
let a_artifacts = a_artifacts.await.expect("A's counter joins");
assert_eq!(
a_artifacts, 1,
"the executor emits exactly one artifact, and the replica running it sees it"
);
assert_eq!(
b_artifacts, 0,
"event queues are per-process, so a subscriber on the other replica \
sees the terminal state but not the frames that led to it — if this \
now fails, events have started crossing replicas and the docs saying \
they do not need rewriting"
);
let _ = a.shutdown().await;
let _ = b.shutdown().await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn independent_limiters_admit_their_limit_each() {
const LIMIT: u64 = 5;
let config = RateLimitConfig {
requests_per_window: LIMIT,
window_secs: 300,
..RateLimitConfig::default()
};
let a = RateLimitInterceptor::new(config.clone()).expect("limiter A");
let b = RateLimitInterceptor::new(config).expect("limiter B");
let admitted = admit_count(&a, "caller-1").await + admit_count(&b, "caller-1").await;
assert_eq!(
admitted,
LIMIT * 2,
"two independent in-process limiters admit their limit each, which is \
the per-replica multiplication the shared counter exists to remove"
);
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn limiters_sharing_a_counter_enforce_one_global_limit() {
const LIMIT: u64 = 5;
let db = TestDb::create("ratelimit").await;
let counter = Arc::new(
a2a_protocol_server::PostgresRateLimitCounter::new(&db.url())
.await
.expect("counter connects and migrates"),
);
let config = RateLimitConfig {
requests_per_window: LIMIT,
window_secs: 300,
..RateLimitConfig::default()
};
let a = RateLimitInterceptor::new(config.clone())
.expect("limiter A")
.with_shared_counter(counter.clone());
let b = RateLimitInterceptor::new(config)
.expect("limiter B")
.with_shared_counter(counter.clone());
let from_a = admit_count(&a, "caller-shared").await;
let from_b = admit_count(&b, "caller-shared").await;
assert_eq!(
from_a + from_b,
LIMIT,
"two replicas sharing a counter must admit the configured limit once \
between them, not once each (A admitted {from_a}, B admitted {from_b})"
);
assert_eq!(
from_a, LIMIT,
"A ran first and should take the whole budget"
);
assert_eq!(from_b, 0, "B should find the budget already spent");
db.drop_db().await;
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn a_shared_counter_still_separates_callers() {
const LIMIT: u64 = 3;
let db = TestDb::create("ratelimit_callers").await;
let counter = Arc::new(
a2a_protocol_server::PostgresRateLimitCounter::new(&db.url())
.await
.expect("counter connects"),
);
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: LIMIT,
window_secs: 300,
..RateLimitConfig::default()
})
.expect("limiter")
.with_shared_counter(counter);
assert_eq!(admit_count(&limiter, "alice").await, LIMIT);
assert_eq!(
admit_count(&limiter, "bob").await,
LIMIT,
"bob has his own budget; a counter that ignored the key would give him none"
);
db.drop_db().await;
}
async fn admit_count(limiter: &RateLimitInterceptor, caller: &str) -> u64 {
use a2a_protocol_server::interceptor::ServerInterceptor;
let mut admitted = 0;
for _ in 0..100 {
let ctx = a2a_protocol_server::CallContext::new("SendMessage")
.with_caller_identity(caller.to_string())
.with_http_headers(HashMap::new());
if limiter.before(&ctx).await.is_err() {
break;
}
admitted += 1;
}
admitted
}