1use crate::response::{ApiErrorDetail, ApiResponse};
2use thiserror::Error;
3
4#[derive(Debug, Clone, Copy)]
5pub enum ErrorCode {
6 Validation,
7 Unauthorized,
8 Forbidden,
9 NotFound,
10 Conflict,
11 RateLimited,
12 Internal,
13 External,
14 ServiceUnavailable,
15}
16
17impl ErrorCode {
18 pub fn as_str(self) -> &'static str {
19 match self {
20 ErrorCode::Validation => "VALIDATION_ERROR",
21 ErrorCode::Unauthorized => "UNAUTHORIZED",
22 ErrorCode::Forbidden => "FORBIDDEN",
23 ErrorCode::NotFound => "NOT_FOUND",
24 ErrorCode::Conflict => "CONFLICT",
25 ErrorCode::RateLimited => "RATE_LIMITED",
26 ErrorCode::Internal => "INTERNAL_ERROR",
27 ErrorCode::External => "EXTERNAL_ERROR",
28 ErrorCode::ServiceUnavailable => "SERVICE_UNAVAILABLE",
29 }
30 }
31}
32
33#[derive(Error, Debug)]
34pub enum AppError {
35 #[error("Validation failed")]
36 Validation {
37 message: String,
38 #[source]
39 source: Option<anyhow::Error>,
40 details: Vec<ApiErrorDetail>,
41 },
42
43 #[error("Unauthorized: {message}")]
44 Unauthorized { message: String },
45
46 #[error("Forbidden: {message}")]
47 Forbidden { message: String },
48
49 #[error("Not found: {message}")]
50 NotFound { message: String },
51
52 #[error("Conflict: {message}")]
53 Conflict { message: String },
54
55 #[error("Rate limited: {message}")]
56 RateLimited { message: String },
57
58 #[error("Internal error")]
59 Internal {
60 message: String,
61 #[source]
62 source: Option<anyhow::Error>,
63 },
64
65 #[error("External dependency error")]
66 External {
67 message: String,
68 #[source]
69 source: Option<anyhow::Error>,
70 },
71
72 #[error("Service unavailable: {message}")]
73 ServiceUnavailable { message: String },
74}
75
76impl AppError {
77 pub fn code(&self) -> ErrorCode {
78 match self {
79 AppError::Validation { .. } => ErrorCode::Validation,
80 AppError::Unauthorized { .. } => ErrorCode::Unauthorized,
81 AppError::Forbidden { .. } => ErrorCode::Forbidden,
82 AppError::NotFound { .. } => ErrorCode::NotFound,
83 AppError::Conflict { .. } => ErrorCode::Conflict,
84 AppError::RateLimited { .. } => ErrorCode::RateLimited,
85 AppError::Internal { .. } => ErrorCode::Internal,
86 AppError::External { .. } => ErrorCode::External,
87 AppError::ServiceUnavailable { .. } => ErrorCode::ServiceUnavailable,
88 }
89 }
90
91 pub fn public_message(&self) -> String {
92 match self {
93 AppError::Validation { message, .. } => message.clone(),
94 AppError::Unauthorized { message } => message.clone(),
95 AppError::Forbidden { message } => message.clone(),
96 AppError::NotFound { message } => message.clone(),
97 AppError::Conflict { message } => message.clone(),
98 AppError::RateLimited { message } => message.clone(),
99 AppError::Internal { .. } => "Internal server error".to_string(),
100 AppError::External { .. } => "Upstream service error".to_string(),
101 AppError::ServiceUnavailable { message } => message.clone(),
102 }
103 }
104
105 pub fn validation(message: impl Into<String>, details: Vec<ApiErrorDetail>) -> Self {
106 Self::Validation {
107 message: message.into(),
108 source: None,
109 details,
110 }
111 }
112
113 pub fn internal(message: impl Into<String>, source: Option<anyhow::Error>) -> Self {
114 Self::Internal {
115 message: message.into(),
116 source,
117 }
118 }
119
120 pub fn external(message: impl Into<String>, source: Option<anyhow::Error>) -> Self {
121 Self::External {
122 message: message.into(),
123 source,
124 }
125 }
126
127 pub fn to_api_response(&self, request_id: impl Into<String>) -> ApiResponse<()> {
128 let rid = request_id.into();
129 match self {
130 AppError::Validation { details, .. } => {
131 let mut errs = details.clone();
132 if errs.is_empty() {
133 errs.push(ApiErrorDetail::new(self.code().as_str(), self.public_message()));
134 }
135 ApiResponse::err(errs, rid)
136 }
137 _ => ApiResponse::err(
138 vec![ApiErrorDetail::new(self.code().as_str(), self.public_message())],
139 rid,
140 ),
141 }
142 }
143}
144
145#[cfg(feature = "web")]
146mod web_impl {
147 use super::*;
148 use actix_web::{http::StatusCode, HttpResponse, ResponseError};
149
150 impl AppError {
151 pub fn http_status(&self) -> StatusCode {
152 match self {
153 AppError::Validation { .. } => StatusCode::BAD_REQUEST,
154 AppError::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
155 AppError::Forbidden { .. } => StatusCode::FORBIDDEN,
156 AppError::NotFound { .. } => StatusCode::NOT_FOUND,
157 AppError::Conflict { .. } => StatusCode::CONFLICT,
158 AppError::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
159 AppError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
160 AppError::External { .. } => StatusCode::BAD_GATEWAY,
161 AppError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
162 }
163 }
164 }
165
166 impl ResponseError for AppError {
168 fn status_code(&self) -> StatusCode {
169 self.http_status()
170 }
171
172 fn error_response(&self) -> HttpResponse {
173 let request_id = uuid::Uuid::new_v4().to_string();
176
177 #[cfg(feature = "observability")]
178 tracing::error!(error_code = %self.code().as_str(), error = ?self, "request failed");
179
180 let body = self.to_api_response(request_id);
181 HttpResponse::build(self.status_code()).json(body)
182 }
183 }
184}