Backend: - Fail fast on empty JWT_SECRET at startup - Restrict CORS to explicit methods (GET/POST/PUT/DELETE/OPTIONS) and headers (Authorization, Content-Type) instead of Any - Add 1MB request body size limit (DefaultBodyLimit) - Add gRPC status mappings: DeadlineExceeded->408, Cancelled->499, AlreadyExists->409, ResourceExhausted->429, Unavailable->503 Frontend: - Guard ErrorBoundary console.error behind import.meta.env.DEV - Read VITE_API_URL and VITE_WS_URL from env vars with fallbacks - Add max reconnect attempts (50) to WebSocket manager - Tune React Query: staleTime 10s, refetchInterval 15s, disable refetchOnWindowFocus to prevent thundering herd Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
5.8 KiB
Rust
163 lines
5.8 KiB
Rust
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use serde_json::json;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error("Authentication required")]
|
|
Unauthorized,
|
|
#[error("Forbidden: {0}")]
|
|
Forbidden(String),
|
|
#[error("Not found: {0}")]
|
|
NotFound(String),
|
|
#[error("Bad request: {0}")]
|
|
BadRequest(String),
|
|
#[error("gRPC error: {0}")]
|
|
GrpcError(#[from] tonic::Status),
|
|
#[error("Internal error: {0}")]
|
|
Internal(#[from] anyhow::Error),
|
|
}
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let (status, message) = match &self {
|
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
|
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
|
AppError::GrpcError(s) => (
|
|
#[allow(clippy::wildcard_enum_match_arm)]
|
|
match s.code() {
|
|
tonic::Code::NotFound => StatusCode::NOT_FOUND,
|
|
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
|
|
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
|
|
tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED,
|
|
tonic::Code::DeadlineExceeded => StatusCode::REQUEST_TIMEOUT,
|
|
tonic::Code::Cancelled => StatusCode::from_u16(499)
|
|
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
|
tonic::Code::AlreadyExists => StatusCode::CONFLICT,
|
|
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
|
|
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
},
|
|
s.message().to_owned(),
|
|
),
|
|
AppError::Internal(_) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Internal server error".to_owned(),
|
|
),
|
|
};
|
|
|
|
let body = json!({ "error": message });
|
|
(status, axum::Json(body)).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::response::IntoResponse;
|
|
|
|
#[test]
|
|
fn test_unauthorized_status() {
|
|
let resp = AppError::Unauthorized.into_response();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_forbidden_status() {
|
|
let resp = AppError::Forbidden("denied".into()).into_response();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_found_status() {
|
|
let resp = AppError::NotFound("missing".into()).into_response();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bad_request_status() {
|
|
let resp = AppError::BadRequest("invalid".into()).into_response();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_internal_error_status() {
|
|
let err = anyhow::anyhow!("something broke");
|
|
let resp = AppError::Internal(err).into_response();
|
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_not_found_maps_to_404() {
|
|
let status = tonic::Status::not_found("no such resource");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_permission_denied_maps_to_403() {
|
|
let status = tonic::Status::permission_denied("forbidden");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_invalid_argument_maps_to_400() {
|
|
let status = tonic::Status::invalid_argument("bad field");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_unauthenticated_maps_to_401() {
|
|
let status = tonic::Status::unauthenticated("no token");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_unknown_maps_to_500() {
|
|
let status = tonic::Status::internal("crash");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_deadline_exceeded_maps_to_408() {
|
|
let status = tonic::Status::deadline_exceeded("timed out");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_cancelled_maps_to_499() {
|
|
let status = tonic::Status::cancelled("client cancelled");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status().as_u16(), 499);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_already_exists_maps_to_409() {
|
|
let status = tonic::Status::already_exists("duplicate");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_resource_exhausted_maps_to_429() {
|
|
let status = tonic::Status::resource_exhausted("rate limited");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_unavailable_maps_to_503() {
|
|
let status = tonic::Status::unavailable("service down");
|
|
let resp = AppError::GrpcError(status).into_response();
|
|
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
}
|