use std::future::Future;
use std::pin::Pin;
#[cfg(feature = "postgres")]
use a2a_protocol_types::error::A2aError;
use a2a_protocol_types::error::A2aResult;
pub trait RateLimitCounter: Send + Sync + 'static {
fn count<'a>(
&'a self,
key: &'a str,
window: u64,
window_secs: u64,
) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>>;
}
#[cfg(feature = "postgres")]
mod postgres {
use super::{A2aError, A2aResult, Future, Pin, RateLimitCounter};
use std::sync::atomic::{AtomicU64, Ordering};
const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS a2a_rate_limit (
caller TEXT NOT NULL,
window_no BIGINT NOT NULL,
request_count BIGINT NOT NULL,
PRIMARY KEY (caller, window_no)
)";
const COUNT_SQL: &str = "INSERT INTO a2a_rate_limit (caller, window_no, request_count) \
VALUES ($1, $2, 1) \
ON CONFLICT (caller, window_no) \
DO UPDATE SET request_count = a2a_rate_limit.request_count + 1 \
RETURNING request_count";
const SWEEP_SQL: &str = "DELETE FROM a2a_rate_limit WHERE window_no < $1";
const SWEEP_INTERVAL: u64 = 1_000;
pub struct PostgresRateLimitCounter {
pool: sqlx::PgPool,
counted: AtomicU64,
}
impl std::fmt::Debug for PostgresRateLimitCounter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PostgresRateLimitCounter")
.finish_non_exhaustive()
}
}
impl PostgresRateLimitCounter {
pub async fn new(url: &str) -> A2aResult<Self> {
let pool = sqlx::postgres::PgPoolOptions::new()
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("SET synchronous_commit = off")
.execute(&mut *conn)
.await
.map(|_| ())
})
})
.connect(url)
.await
.map_err(|e| A2aError::internal(format!("rate-limit counter connect: {e}")))?;
Self::from_pool(pool).await
}
pub async fn from_pool(pool: sqlx::PgPool) -> A2aResult<Self> {
crate::store::pg_schema::apply(&pool, &[CREATE_TABLE_SQL])
.await
.map_err(|e| A2aError::internal(format!("rate-limit counter migrate: {e}")))?;
Ok(Self {
pool,
counted: AtomicU64::new(0),
})
}
async fn sweep(&self, current_window: u64) {
let cutoff = i64::try_from(current_window).unwrap_or(i64::MAX);
if let Err(_e) = sqlx::query(SWEEP_SQL)
.bind(cutoff)
.execute(&self.pool)
.await
{
trace_warn!(error = %_e, "rate-limit counter sweep failed");
}
}
}
impl RateLimitCounter for PostgresRateLimitCounter {
fn count<'a>(
&'a self,
key: &'a str,
window: u64,
_window_secs: u64,
) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
Box::pin(async move {
let window_no = i64::try_from(window)
.map_err(|_| A2aError::internal("rate-limit window out of range"))?;
let (count,): (i64,) = sqlx::query_as(COUNT_SQL)
.bind(key)
.bind(window_no)
.fetch_one(&self.pool)
.await
.map_err(|e| A2aError::internal(format!("rate-limit counter: {e}")))?;
let n = self.counted.fetch_add(1, Ordering::Relaxed);
if n > 0 && n.is_multiple_of(SWEEP_INTERVAL) {
self.sweep(window).await;
}
Ok(u64::try_from(count).unwrap_or(u64::MAX))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::postgres::PgPool;
const URL_ENV: &str = "A2A_TEST_POSTGRES_URL";
#[tokio::test]
async fn debug_names_the_counter_and_elides_the_pool() {
let counter = PostgresRateLimitCounter {
pool: PgPool::connect_lazy("postgres://nobody@127.0.0.1:1/nowhere")
.expect("a lazy pool needs no server"),
counted: AtomicU64::new(0),
};
let shown = format!("{counter:?}");
assert!(
shown.starts_with("PostgresRateLimitCounter"),
"Debug must name the type, got: {shown}"
);
assert!(
shown.contains(".."),
"the pool is elided rather than dumped, got: {shown}"
);
}
struct ScratchDb {
admin_url: String,
name: String,
}
impl ScratchDb {
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 Postgres suite"));
let name = format!("a2a_rl_counter_{tag}_{}", std::process::id());
let pool = 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) = PgPool::connect(&self.admin_url).await {
let _ = sqlx::query(&format!(
"DROP DATABASE IF EXISTS \"{}\" WITH (FORCE)",
self.name
))
.execute(&pool)
.await;
pool.close().await;
}
}
}
const STALE_WINDOW: i64 = 1;
const LIVE_WINDOW: u64 = 7;
async fn seed_stale_row(pool: &PgPool) {
sqlx::query(
"INSERT INTO a2a_rate_limit (caller, window_no, request_count) VALUES ('stale', $1, 1)",
)
.bind(STALE_WINDOW)
.execute(pool)
.await
.expect("seed a stale window");
}
async fn stale_rows(pool: &PgPool) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM a2a_rate_limit WHERE window_no = $1")
.bind(STALE_WINDOW)
.fetch_one(pool)
.await
.expect("count stale rows")
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn the_first_count_does_not_sweep() {
let db = ScratchDb::create("first").await;
let counter = PostgresRateLimitCounter::new(&db.url())
.await
.expect("counter connects");
seed_stale_row(&counter.pool).await;
let seen = counter
.count("caller", LIVE_WINDOW, 60)
.await
.expect("first count");
assert_eq!(seen, 1, "the first count in a window is 1");
assert_eq!(
stale_rows(&counter.pool).await,
1,
"the stale window survives the first count: no sweep is due yet"
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "needs a live PostgreSQL (see the module docs)"]
async fn the_sweep_runs_on_the_interval_and_only_clears_past_windows() {
let db = ScratchDb::create("interval").await;
let counter = PostgresRateLimitCounter::new(&db.url())
.await
.expect("counter connects");
seed_stale_row(&counter.pool).await;
for _ in 0..SWEEP_INTERVAL {
counter
.count("caller", LIVE_WINDOW, 60)
.await
.expect("count");
}
assert_eq!(
stale_rows(&counter.pool).await,
1,
"SWEEP_INTERVAL counts have been made and the sweep has not yet run"
);
let seen = counter
.count("caller", LIVE_WINDOW, 60)
.await
.expect("count");
assert_eq!(seen, SWEEP_INTERVAL + 1);
assert_eq!(
stale_rows(&counter.pool).await,
0,
"the sweep on count SWEEP_INTERVAL removes the stale window"
);
let live: i64 = sqlx::query_scalar(
"SELECT request_count FROM a2a_rate_limit WHERE caller = 'caller' AND window_no = $1",
)
.bind(i64::try_from(LIVE_WINDOW).expect("fits"))
.fetch_one(&counter.pool)
.await
.expect("the live window's row");
assert_eq!(
u64::try_from(live).expect("non-negative"),
SWEEP_INTERVAL + 1,
"the sweep only clears windows before the current one"
);
db.drop_db().await;
}
}
}
#[cfg(feature = "postgres")]
pub use postgres::PostgresRateLimitCounter;