1use std::sync::Arc;
2
3use ares_types::models::{QuotaExceeded, TenantContext};
4use ares_types::types::AppError;
5use cordis::{Context, CordisError, EventsService};
6use serde_json::Value;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum UsagePeriod {
11 Monthly,
12 Daily,
13}
14
15#[derive(Debug)]
17pub enum AdmissionError {
18 Usage {
19 period: UsagePeriod,
20 source: AppError,
21 },
22 Event(CordisError),
23 Quota(QuotaExceeded),
24}
25
26impl From<AdmissionError> for AppError {
27 fn from(error: AdmissionError) -> Self {
28 match error {
29 AdmissionError::Usage { source, .. } => source,
30 AdmissionError::Event(error) => {
31 AppError::Internal(format!("admission event failed: {error}"))
32 }
33 AdmissionError::Quota(exceeded) => exceeded.into(),
34 }
35 }
36}
37
38pub fn quota_exceeded(tenant: &TenantContext, monthly: u64, daily: u64) -> Option<QuotaExceeded> {
40 tenant.admit(monthly, daily).err()
41}
42
43pub async fn admit(ctx: &Arc<Context>) -> Result<(), AppError> {
49 admit_with_details(ctx).await.map_err(Into::into)
50}
51
52pub async fn admit_with_details(ctx: &Arc<Context>) -> Result<(), AdmissionError> {
54 let Some(tc) = ctx.get::<TenantContext>() else {
55 return Ok(());
56 };
57 let (monthly, daily) = usage_counts(ctx, &tc.tenant_id).await?;
58 if let Some(events) = ctx.get::<EventsService>() {
59 let payload = cordis::AgentAdmitPayload {
60 tenant_id: tc.tenant_id.clone(),
61 monthly,
62 daily,
63 requests_per_month: Some(tc.quota.requests_per_month),
64 requests_per_day: Some(tc.quota.requests_per_day),
65 tier: tc.tier.as_str().to_string(),
66 };
67 let result = events
68 .dispatch_typed::<cordis::AgentAdmitEvent>(&payload)
69 .await
70 .map_err(AdmissionError::Event)?;
71 if let Some(err) = deny_from_bail(&result) {
72 return Err(AdmissionError::Quota(err));
73 }
74 }
75 quota_exceeded(&tc, monthly, daily)
76 .map_or(Ok(()), |exceeded| Err(AdmissionError::Quota(exceeded)))
77}
78
79fn deny_from_bail(result: &Value) -> Option<QuotaExceeded> {
80 let marker = result
81 .get("deny")
82 .and_then(|v| v.as_str())
83 .or_else(|| result.get("error").and_then(|v| v.as_str()));
84 match marker {
85 Some("daily") => Some(QuotaExceeded::Daily),
86 Some("monthly") | Some(_) => Some(QuotaExceeded::Monthly),
87 None => None,
88 }
89}
90
91async fn usage_counts(ctx: &Arc<Context>, tenant_id: &str) -> Result<(u64, u64), AdmissionError> {
92 #[cfg(feature = "postgres")]
93 {
94 if let Some(db) = ctx.get::<ares_store::TenantDb>() {
95 let monthly = db.get_monthly_requests(tenant_id).await.map_err(|source| {
96 AdmissionError::Usage {
97 period: UsagePeriod::Monthly,
98 source,
99 }
100 })?;
101 let daily =
102 db.get_daily_requests(tenant_id)
103 .await
104 .map_err(|source| AdmissionError::Usage {
105 period: UsagePeriod::Daily,
106 source,
107 })?;
108 return Ok((monthly, daily));
109 }
110 }
111 let _ = (ctx, tenant_id);
112 Ok((0, 0))
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use ares_types::models::TenantTier;
119 use serde_json::json;
120
121 fn free_tenant() -> TenantContext {
122 TenantContext::new("acme".into(), TenantTier::Free)
123 }
124
125 fn ctx_with_deny(deny: &'static str) -> (Arc<Context>, Box<dyn cordis::Disposable>) {
126 let root = Context::new_root();
127 let events = root.provide(EventsService::new());
128 let keep = events.on(
129 cordis::events_catalog::ev::AGENT_ADMIT.to_string(),
130 move |_payload| async move { Ok(json!({ "deny": deny })) },
131 );
132 let ctx = root.with_intercept(free_tenant());
133 (ctx, keep)
134 }
135
136 #[tokio::test]
137 async fn bail_deny_monthly_overrides_passing_typed_quota() {
138 let tenant = free_tenant();
139 assert!(
140 tenant.admit(0, 0).is_ok(),
141 "typed Free quota must pass at zero usage"
142 );
143 let (ctx, _keep) = ctx_with_deny("monthly");
144 let err = admit_with_details(&ctx)
145 .await
146 .expect_err("event deny must win over typed pass");
147 assert!(matches!(err, AdmissionError::Quota(QuotaExceeded::Monthly)));
148 }
149
150 #[tokio::test]
151 async fn bail_deny_daily_overrides_passing_typed_quota() {
152 let tenant = free_tenant();
153 assert!(
154 tenant.admit(0, 0).is_ok(),
155 "typed Free quota must pass at zero usage"
156 );
157 let (ctx, _keep) = ctx_with_deny("daily");
158 let err = admit_with_details(&ctx)
159 .await
160 .expect_err("event deny must win over typed pass");
161 assert!(matches!(err, AdmissionError::Quota(QuotaExceeded::Daily)));
162 }
163
164 #[tokio::test]
165 async fn admit_without_events_uses_typed_fallback() {
166 let ctx = Context::new_root().with_intercept(free_tenant());
167 assert!(
168 ctx.get::<EventsService>().is_none(),
169 "this path must not install EventsService"
170 );
171 assert!(quota_exceeded(&free_tenant(), 0, 0).is_none());
172 admit_with_details(&ctx)
173 .await
174 .expect("typed fallback admits Free quota at zero usage");
175 }
176}