Per-user autosave rate limit (30/min, 429 past it)
The exam screen autosaves on every answer; a stuck client retrying in a tight loop could hammer SQLite's single writer. Ctx carries a small in-memory per-user windowed counter with opportunistic cleanup — no Redis needed. Verified live: 30x200 then 429s.
This commit is contained in:
parent
dc754fe996
commit
86c883fb91
2 changed files with 33 additions and 0 deletions
26
src/auth.rs
26
src/auth.rs
|
|
@ -20,9 +20,35 @@ pub struct Ctx {
|
|||
/// change on import, so there is no reason to run five aggregate
|
||||
/// subselects on every homepage hit.
|
||||
pub stats_cache: std::sync::Mutex<(std::time::Instant, (i64, i64, i64, i64, i64))>,
|
||||
/// Autosave rate limiter: user id -> (window start, hits in window).
|
||||
/// The exam screen autosaves on every answer, so a stuck client retrying
|
||||
/// in a tight loop could otherwise hammer SQLite's single writer.
|
||||
pub save_hits: std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, u32)>>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
/// True when this user may autosave now (30 saves per rolling minute).
|
||||
pub fn save_allowed(&self, uid: &str) -> bool {
|
||||
const LIMIT: u32 = 30;
|
||||
const WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut guard = self.save_hits.lock().unwrap();
|
||||
// Opportunistic cleanup so the map cannot grow without bound.
|
||||
if guard.len() > 4096 {
|
||||
let now = std::time::Instant::now();
|
||||
guard.retain(|_, (t, _)| now.duration_since(*t) < WINDOW * 2);
|
||||
}
|
||||
let entry = guard.entry(uid.to_string()).or_insert((std::time::Instant::now(), 0));
|
||||
if entry.0.elapsed() >= WINDOW {
|
||||
*entry = (std::time::Instant::now(), 1);
|
||||
return true;
|
||||
}
|
||||
if entry.1 >= LIMIT {
|
||||
return false;
|
||||
}
|
||||
entry.1 += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn bank_stats(&self) -> (i64, i64, i64, i64, i64) {
|
||||
{
|
||||
let guard = self.stats_cache.lock().unwrap();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
std::time::Instant::now() - std::time::Duration::from_secs(61),
|
||||
(0, 0, 0, 0, 0),
|
||||
)),
|
||||
save_hits: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
});
|
||||
let static_dir = format!("{}/static", std::env::current_dir()?.display());
|
||||
|
||||
|
|
@ -383,6 +384,12 @@ async fn save_answers(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Pa
|
|||
Some(u) => u,
|
||||
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
if !ctx.save_allowed(&uid) {
|
||||
return hx(
|
||||
"{\"error\":\"saving too fast, retry shortly\"}".into(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
match exam::save_answers(&ctx.pool, &attempt, &uid, &body).await {
|
||||
Ok(_) => hx("{\"ok\":true}".into(), StatusCode::OK),
|
||||
Err(e) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue