use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::Error;
#[derive(Debug, Clone, Copy)]
pub enum RateCategory {
GroupCreate,
NotecardCreate,
InvitationCreate,
}
impl RateCategory {
const fn as_str(self) -> &'static str {
match self {
Self::GroupCreate => "group_create",
Self::NotecardCreate => "notecard_create",
Self::InvitationCreate => "invitation_create",
}
}
const fn parameters(self) -> (u32, u32) {
match self {
Self::GroupCreate => (12, 1200),
Self::NotecardCreate => (60, 360),
Self::InvitationCreate => (250, 360),
}
}
}
pub async fn try_acquire(
db: &SqlitePool,
category: RateCategory,
user_id: Uuid,
) -> Result<(), Error> {
let (capacity, seconds_per_token) = category.parameters();
let now = Utc::now();
let row: Option<(f64,)> = sqlx::query_as(
"INSERT INTO rate_buckets (category, user_id, tokens, last_refill_at) \
VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(category, user_id) DO UPDATE SET \
tokens = MIN(?5, rate_buckets.tokens + \
(julianday(excluded.last_refill_at) \
- julianday(rate_buckets.last_refill_at)) * 86400.0 / ?6) - 1, \
last_refill_at = excluded.last_refill_at \
WHERE MIN(?5, rate_buckets.tokens + \
(julianday(excluded.last_refill_at) \
- julianday(rate_buckets.last_refill_at)) * 86400.0 / ?6) >= 1 \
RETURNING tokens",
)
.bind(category.as_str())
.bind(user_id.as_bytes().to_vec())
.bind(f64::from(capacity) - 1.0)
.bind(now)
.bind(f64::from(capacity))
.bind(f64::from(seconds_per_token))
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("rate_limit acquire failed: {err}");
Error::Database
})?;
if row.is_some() {
Ok(())
} else {
Err(Error::TooManyRequests {
retry_after_secs: u64::from(seconds_per_token),
})
}
}