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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-03 18:37:51 +02:00
parent 3dc69e6c6d
commit de0e395a9e
8 changed files with 113 additions and 24 deletions

View File

@@ -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<u64>),
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
}
}