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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user