Files
foxhunt/web-gateway/src/error.rs
jgrusewski 544315e351 safety(web-gateway,web-dashboard): harden for production deployment
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>
2026-02-22 08:34:37 +01:00

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);
}
}