use std::sync::Arc;
use ares_types::models::{QuotaExceeded, TenantContext};
use ares_types::types::AppError;
use cordis::{Context, CordisError, EventsService};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsagePeriod {
Monthly,
Daily,
}
#[derive(Debug)]
pub enum AdmissionError {
Usage {
period: UsagePeriod,
source: AppError,
},
Event(CordisError),
Quota(QuotaExceeded),
}
impl From<AdmissionError> for AppError {
fn from(error: AdmissionError) -> Self {
match error {
AdmissionError::Usage { source, .. } => source,
AdmissionError::Event(error) => {
AppError::Internal(format!("admission event failed: {error}"))
}
AdmissionError::Quota(exceeded) => exceeded.into(),
}
}
}
pub fn quota_exceeded(tenant: &TenantContext, monthly: u64, daily: u64) -> Option<QuotaExceeded> {
tenant.admit(monthly, daily).err()
}
pub async fn admit(ctx: &Arc<Context>) -> Result<(), AppError> {
admit_with_details(ctx).await.map_err(Into::into)
}
pub async fn admit_with_details(ctx: &Arc<Context>) -> Result<(), AdmissionError> {
let Some(tc) = ctx.get::<TenantContext>() else {
return Ok(());
};
let (monthly, daily) = usage_counts(ctx, &tc.tenant_id).await?;
if let Some(events) = ctx.get::<EventsService>() {
let payload = cordis::AgentAdmitPayload {
tenant_id: tc.tenant_id.clone(),
monthly,
daily,
requests_per_month: Some(tc.quota.requests_per_month),
requests_per_day: Some(tc.quota.requests_per_day),
tier: tc.tier.as_str().to_string(),
};
let result = events
.dispatch_typed::<cordis::AgentAdmitEvent>(&payload)
.await
.map_err(AdmissionError::Event)?;
if let Some(err) = deny_from_bail(&result) {
return Err(AdmissionError::Quota(err));
}
}
quota_exceeded(&tc, monthly, daily)
.map_or(Ok(()), |exceeded| Err(AdmissionError::Quota(exceeded)))
}
fn deny_from_bail(result: &Value) -> Option<QuotaExceeded> {
let marker = result
.get("deny")
.and_then(|v| v.as_str())
.or_else(|| result.get("error").and_then(|v| v.as_str()));
match marker {
Some("daily") => Some(QuotaExceeded::Daily),
Some("monthly") | Some(_) => Some(QuotaExceeded::Monthly),
None => None,
}
}
async fn usage_counts(ctx: &Arc<Context>, tenant_id: &str) -> Result<(u64, u64), AdmissionError> {
#[cfg(feature = "postgres")]
{
if let Some(db) = ctx.get::<ares_store::TenantDb>() {
let monthly = db.get_monthly_requests(tenant_id).await.map_err(|source| {
AdmissionError::Usage {
period: UsagePeriod::Monthly,
source,
}
})?;
let daily =
db.get_daily_requests(tenant_id)
.await
.map_err(|source| AdmissionError::Usage {
period: UsagePeriod::Daily,
source,
})?;
return Ok((monthly, daily));
}
}
let _ = (ctx, tenant_id);
Ok((0, 0))
}
#[cfg(test)]
mod tests {
use super::*;
use ares_types::models::TenantTier;
use serde_json::json;
fn free_tenant() -> TenantContext {
TenantContext::new("acme".into(), TenantTier::Free)
}
fn ctx_with_deny(deny: &'static str) -> (Arc<Context>, Box<dyn cordis::Disposable>) {
let root = Context::new_root();
let events = root.provide(EventsService::new());
let keep = events.on(
cordis::events_catalog::ev::AGENT_ADMIT.to_string(),
move |_payload| async move { Ok(json!({ "deny": deny })) },
);
let ctx = root.with_intercept(free_tenant());
(ctx, keep)
}
#[tokio::test]
async fn bail_deny_monthly_overrides_passing_typed_quota() {
let tenant = free_tenant();
assert!(
tenant.admit(0, 0).is_ok(),
"typed Free quota must pass at zero usage"
);
let (ctx, _keep) = ctx_with_deny("monthly");
let err = admit_with_details(&ctx)
.await
.expect_err("event deny must win over typed pass");
assert!(matches!(err, AdmissionError::Quota(QuotaExceeded::Monthly)));
}
#[tokio::test]
async fn bail_deny_daily_overrides_passing_typed_quota() {
let tenant = free_tenant();
assert!(
tenant.admit(0, 0).is_ok(),
"typed Free quota must pass at zero usage"
);
let (ctx, _keep) = ctx_with_deny("daily");
let err = admit_with_details(&ctx)
.await
.expect_err("event deny must win over typed pass");
assert!(matches!(err, AdmissionError::Quota(QuotaExceeded::Daily)));
}
#[tokio::test]
async fn admit_without_events_uses_typed_fallback() {
let ctx = Context::new_root().with_intercept(free_tenant());
assert!(
ctx.get::<EventsService>().is_none(),
"this path must not install EventsService"
);
assert!(quota_exceeded(&free_tenant(), 0, 0).is_none());
admit_with_details(&ctx)
.await
.expect("typed fallback admits Free quota at zero usage");
}
}