From de0e395a9e646839a1f810ee5d5f01947ef4bbd4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 3 Apr 2026 18:37:51 +0200 Subject: [PATCH] feat: auto-retry uploads when rate limited (v0.13.0) Backend: rate limiter gains check_with_retry() returning seconds until the next slot opens. Upload 429 responses include retry_after_secs in JSON and a Retry-After header. Frontend: upload queue catches 429 as RateLimitError, resets affected item to pending, schedules processQueue() for the server-reported delay, and shows a live countdown banner in the queue UI. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/auth/handlers.rs | 2 + backend/src/error.rs | 31 ++++++++++--- backend/src/handlers/admin.rs | 8 +--- backend/src/handlers/feed.rs | 4 +- backend/src/handlers/upload.rs | 5 ++- backend/src/services/rate_limiter.rs | 15 +++++-- .../src/lib/components/UploadQueue.svelte | 27 ++++++++++- frontend/src/lib/upload-queue.ts | 45 +++++++++++++++++-- 8 files changed, 113 insertions(+), 24 deletions(-) diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index ecc5c43..75cac91 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -39,6 +39,7 @@ pub async fn join( if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) { return Err(AppError::TooManyRequests( "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + None, )); } @@ -124,6 +125,7 @@ pub async fn recover( if Utc::now() < locked_until { return Err(AppError::TooManyRequests( "Zu viele Versuche. Bitte warte 15 Minuten.".into(), + None, )); } } diff --git a/backend/src/error.rs b/backend/src/error.rs index 933b250..4e9d977 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -1,6 +1,5 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use serde_json::json; #[derive(Debug)] pub enum AppError { @@ -8,7 +7,9 @@ pub enum AppError { Unauthorized(String), Forbidden(String), NotFound(String), - TooManyRequests(String), + Conflict(String), + /// Second field: optional retry-after seconds to include in the response. + TooManyRequests(String, Option), Internal(anyhow::Error), } @@ -19,7 +20,8 @@ impl AppError { Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), - Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), + Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), + Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), } } @@ -30,7 +32,8 @@ impl AppError { | Self::Unauthorized(msg) | Self::Forbidden(msg) | Self::NotFound(msg) - | Self::TooManyRequests(msg) => msg.clone(), + | Self::Conflict(msg) => msg.clone(), + Self::TooManyRequests(msg, _) => msg.clone(), Self::Internal(err) => { tracing::error!("internal error: {err:#}"); "Ein interner Fehler ist aufgetreten.".to_string() @@ -42,13 +45,29 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code) = self.status_and_code(); + let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self { + Some(*secs) + } else { + None + }; let message = self.message(); - let body = json!({ + + let mut body = serde_json::json!({ "error": code, "message": message, "status": status.as_u16(), }); - (status, axum::Json(body)).into_response() + if let Some(secs) = retry_after_secs { + body["retry_after_secs"] = secs.into(); + } + + let mut resp = (status, axum::Json(body)).into_response(); + if let Some(secs) = retry_after_secs { + if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) { + resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val); + } + } + resp } } diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 3357628..7b1d039 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -180,9 +180,7 @@ pub async fn download_zip( let ip = client_ip(&headers, "unknown"); let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await; if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) { - return Err(AppError::TooManyRequests( - "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), - )); + return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None)); } let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) @@ -211,9 +209,7 @@ pub async fn download_html( let ip = client_ip(&headers, "unknown"); let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await; if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) { - return Err(AppError::TooManyRequests( - "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), - )); + return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None)); } let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs index 78f3441..55ae632 100644 --- a/backend/src/handlers/feed.rs +++ b/backend/src/handlers/feed.rs @@ -63,9 +63,7 @@ pub async fn feed( let ip = client_ip(&headers, "unknown"); let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await; if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) { - return Err(AppError::TooManyRequests( - "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), - )); + return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None)); } let limit = q.limit.unwrap_or(20).min(100); diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 0441a95..001c8a4 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -20,13 +20,14 @@ pub async fn upload( ) -> Result<(StatusCode, Json), AppError> { // Rate limit: N uploads per hour per user let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize; - if !state + if let Err(retry_after_secs) = state .rate_limiter - .check(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600)) + .check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600)) { drain_multipart(multipart).await; return Err(AppError::TooManyRequests( "Du hast dein Upload-Limit für diese Stunde erreicht.".into(), + Some(retry_after_secs), )); } diff --git a/backend/src/services/rate_limiter.rs b/backend/src/services/rate_limiter.rs index 4a4bfc0..b45fb0f 100644 --- a/backend/src/services/rate_limiter.rs +++ b/backend/src/services/rate_limiter.rs @@ -19,17 +19,26 @@ impl RateLimiter { /// Returns `true` if the request is allowed, `false` if rate-limited. pub fn check(&self, key: impl Into, max: usize, window: Duration) -> bool { + self.check_with_retry(key, max, window).is_ok() + } + + /// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited. + /// `retry_after_secs` is how long until the oldest slot in the window expires. + pub fn check_with_retry(&self, key: impl Into, max: usize, window: Duration) -> Result<(), u64> { let now = Instant::now(); let key = key.into(); let mut map = self.windows.lock().unwrap(); let timestamps = map.entry(key).or_default(); - // Drop entries outside the window timestamps.retain(|&t| now.duration_since(t) < window); if timestamps.len() < max { timestamps.push(now); - true + Ok(()) } else { - false + // The oldest timestamp expires at oldest + window; compute remaining seconds + let oldest = timestamps[0]; + let elapsed = now.duration_since(oldest); + let remaining = window.saturating_sub(elapsed); + Err(remaining.as_secs().max(1)) } } } diff --git a/frontend/src/lib/components/UploadQueue.svelte b/frontend/src/lib/components/UploadQueue.svelte index e67b324..9672d1a 100644 --- a/frontend/src/lib/components/UploadQueue.svelte +++ b/frontend/src/lib/components/UploadQueue.svelte @@ -1,5 +1,5 @@ {#if items.length > 0} @@ -49,6 +68,12 @@ {/if} + {#if $rateLimitRetryAt && countdown > 0} +
+ Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt. +
+ {/if} +
    {#each items as item (item.id)}
  • diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts index eb1fe9f..68cd1fe 100644 --- a/frontend/src/lib/upload-queue.ts +++ b/frontend/src/lib/upload-queue.ts @@ -18,6 +18,9 @@ export interface QueueItem { export const queueItems = writable([]); export const isProcessing = writable(false); +/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */ +export const rateLimitRetryAt = writable(null); + const DB_NAME = 'eventsnap-uploads'; const STORE_NAME = 'queue'; @@ -35,6 +38,14 @@ async function getDb(): Promise { return db; } +class RateLimitError extends Error { + retryAfterSecs: number; + constructor(secs: number) { + super('rate_limited'); + this.retryAfterSecs = secs; + } +} + export async function loadQueue(): Promise { const database = await getDb(); const all = await database.getAll(STORE_NAME); @@ -136,7 +147,21 @@ async function processQueue(): Promise { const next = items.find((item) => item.status === 'pending'); if (!next) break; - await uploadItem(next.id); + try { + await uploadItem(next.id); + } catch (e) { + if (e instanceof RateLimitError) { + // Keep all pending items as-is; schedule queue resume when limit lifts + const retryAt = Date.now() + e.retryAfterSecs * 1000; + rateLimitRetryAt.set(retryAt); + setTimeout(() => { + rateLimitRetryAt.set(null); + processQueue(); + }, e.retryAfterSecs * 1000); + break; + } + // Other errors are already handled inside uploadItem (marked as 'error') + } } } finally { processing = false; @@ -148,7 +173,6 @@ async function uploadItem(id: string): Promise { const database = await getDb(); const entry = await database.get(STORE_NAME, id); if (!entry || !entry.blob) { - // No blob — mark as error updateItemStatus(id, 'error', 'Datei nicht gefunden.'); return; } @@ -184,6 +208,14 @@ async function uploadItem(id: string): Promise { xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); + } else if (xhr.status === 429) { + try { + const body = JSON.parse(xhr.responseText); + const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60; + reject(new RateLimitError(secs)); + } catch { + reject(new RateLimitError(60)); + } } else { try { const body = JSON.parse(xhr.responseText); @@ -205,6 +237,13 @@ async function uploadItem(id: string): Promise { await database.put(STORE_NAME, entry); updateItemStatus(id, 'done'); } catch (e) { + if (e instanceof RateLimitError) { + // Reset to pending so it will be retried when the queue resumes + entry.status = 'pending'; + await database.put(STORE_NAME, entry); + updateItemStatus(id, 'pending'); + throw e; // Propagate to processQueue for scheduling + } const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.'; entry.status = 'error'; entry.error = msg; @@ -224,7 +263,7 @@ function updateItemStatus( ? { ...item, status, - progress: status === 'done' ? 100 : status === 'error' ? item.progress : item.progress, + progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress, error } : item