//! Единый тип ошибки приложения. `AppError` реализует `IntoResponse`, поэтому любой //! контроллер может просто возвращать `Result` — маппинг в HTTP-статус, //! безопасное для клиента сообщение и структурированный лог/метрику делает `into_response`. //! Правило: детали (SQL-ошибки, внутренние сообщения) уходят только в логи, наружу — общие //! формулировки, чтобы не раскрывать внутреннее устройство системы атакующему. use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; use tracing::{error, warn}; #[derive(thiserror::Error, Debug)] pub enum AppError { #[error("User not found")] UserNotFound, #[error("Trial already used")] TrialAlreadyUsed, #[error("Database error: {0}")] Database(#[from] sqlx::Error), #[error("Transaction failed: {0}")] Transaction(String), #[error("Redis error: {0}")] Redis(#[from] redis::RedisError), #[error("Unauthorized: {0}")] Unauthorized(String), #[error("Not found: {0}")] NotFound(String), #[error("Business Logic: {0}")] BusinessLogic(String), #[error("Internal Server Error: {0}")] Internal(String), } impl IntoResponse for AppError { fn into_response(self) -> Response { let error_type = match &self { AppError::Database(_) => "database", AppError::Transaction(_) => "transaction", AppError::UserNotFound => "user_not_found", AppError::TrialAlreadyUsed => "trial_used", AppError::Redis(_) => "redis", AppError::Unauthorized(_) => "unauthorized", AppError::NotFound(_) => "not_found", AppError::BusinessLogic(_) => "business_logic", // FIX: Add `(_)` to ignore the inner string when just grabbing the type AppError::Internal(_) => "internal", }; // Метрики остаются как были metrics::counter!("app_errors_total", "type" => error_type).increment(1); let (status, error_message) = match &self { AppError::UserNotFound => { warn!(error_type, "Attempted action for non-existent user"); (StatusCode::NOT_FOUND, "User profile not found".to_string()) } AppError::TrialAlreadyUsed => { warn!(error_type, "Attempted to activate exhausted trial"); (StatusCode::CONFLICT, "Trial already used".to_string()) } AppError::Database(e) => { error!(error_type, db_error = ?e, "Database execution failure"); ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server database error".to_string(), ) } AppError::Transaction(msg) => { error!(error_type, details = %msg, "Transaction commit failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Transaction processing failed".to_string(), ) } AppError::Redis(e) => { error!(error_type, redis_error = ?e, "Redis connection/command failure"); ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), ) } AppError::Unauthorized(msg) => { warn!(error_type, details = %msg, "Unauthorized access blocked"); (StatusCode::UNAUTHORIZED, msg.clone()) } AppError::NotFound(msg) => { warn!(error_type, details = %msg, "Resource not found"); (StatusCode::NOT_FOUND, msg.clone()) } AppError::BusinessLogic(msg) => { warn!(error_type, details = %msg, "Business logic violation"); (StatusCode::BAD_REQUEST, msg.clone()) } // FIX: Bind the inner string to `msg` and log it! AppError::Internal(msg) => { error!(error_type, details = %msg, "Critical internal failure"); ( StatusCode::INTERNAL_SERVER_ERROR, "Critical failure".to_string(), // We keep the user-facing message safe/generic ) } }; let body = Json(json!({ "success": false, "error": error_message, "code": status.as_u16() })); (status, body).into_response() } }