Skip to main content

ralph_api/
errors.rs

1use axum::http::StatusCode;
2use serde::Serialize;
3use serde_json::Value;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum RpcErrorCode {
7    InvalidRequest,
8    MethodNotFound,
9    InvalidParams,
10    Unauthorized,
11    Forbidden,
12    NotFound,
13    Conflict,
14    PreconditionFailed,
15    RateLimited,
16    Timeout,
17    ServiceUnavailable,
18    Internal,
19    TaskNotFound,
20    LoopNotFound,
21    PlanningSessionNotFound,
22    CollectionNotFound,
23    ConfigInvalid,
24    IdempotencyConflict,
25    BackpressureDropped,
26}
27
28impl RpcErrorCode {
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::InvalidRequest => "INVALID_REQUEST",
32            Self::MethodNotFound => "METHOD_NOT_FOUND",
33            Self::InvalidParams => "INVALID_PARAMS",
34            Self::Unauthorized => "UNAUTHORIZED",
35            Self::Forbidden => "FORBIDDEN",
36            Self::NotFound => "NOT_FOUND",
37            Self::Conflict => "CONFLICT",
38            Self::PreconditionFailed => "PRECONDITION_FAILED",
39            Self::RateLimited => "RATE_LIMITED",
40            Self::Timeout => "TIMEOUT",
41            Self::ServiceUnavailable => "SERVICE_UNAVAILABLE",
42            Self::Internal => "INTERNAL",
43            Self::TaskNotFound => "TASK_NOT_FOUND",
44            Self::LoopNotFound => "LOOP_NOT_FOUND",
45            Self::PlanningSessionNotFound => "PLANNING_SESSION_NOT_FOUND",
46            Self::CollectionNotFound => "COLLECTION_NOT_FOUND",
47            Self::ConfigInvalid => "CONFIG_INVALID",
48            Self::IdempotencyConflict => "IDEMPOTENCY_CONFLICT",
49            Self::BackpressureDropped => "BACKPRESSURE_DROPPED",
50        }
51    }
52
53    pub fn from_contract(value: &str) -> Option<Self> {
54        match value {
55            "INVALID_REQUEST" => Some(Self::InvalidRequest),
56            "METHOD_NOT_FOUND" => Some(Self::MethodNotFound),
57            "INVALID_PARAMS" => Some(Self::InvalidParams),
58            "UNAUTHORIZED" => Some(Self::Unauthorized),
59            "FORBIDDEN" => Some(Self::Forbidden),
60            "NOT_FOUND" => Some(Self::NotFound),
61            "CONFLICT" => Some(Self::Conflict),
62            "PRECONDITION_FAILED" => Some(Self::PreconditionFailed),
63            "RATE_LIMITED" => Some(Self::RateLimited),
64            "TIMEOUT" => Some(Self::Timeout),
65            "SERVICE_UNAVAILABLE" => Some(Self::ServiceUnavailable),
66            "INTERNAL" => Some(Self::Internal),
67            "TASK_NOT_FOUND" => Some(Self::TaskNotFound),
68            "LOOP_NOT_FOUND" => Some(Self::LoopNotFound),
69            "PLANNING_SESSION_NOT_FOUND" => Some(Self::PlanningSessionNotFound),
70            "COLLECTION_NOT_FOUND" => Some(Self::CollectionNotFound),
71            "CONFIG_INVALID" => Some(Self::ConfigInvalid),
72            "IDEMPOTENCY_CONFLICT" => Some(Self::IdempotencyConflict),
73            "BACKPRESSURE_DROPPED" => Some(Self::BackpressureDropped),
74            _ => None,
75        }
76    }
77}
78
79#[derive(Debug, Clone, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct RpcErrorBody {
82    pub code: String,
83    pub message: String,
84    pub retryable: bool,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub details: Option<Value>,
87}
88
89#[derive(Debug, Clone)]
90pub struct ApiError {
91    pub code: RpcErrorCode,
92    pub message: String,
93    pub retryable: bool,
94    pub details: Option<Value>,
95    pub status: StatusCode,
96    pub request_id: String,
97    pub method: Option<String>,
98}
99
100impl ApiError {
101    pub fn new(code: RpcErrorCode, message: impl Into<String>) -> Self {
102        Self {
103            status: status_for_code(code),
104            code,
105            message: message.into(),
106            retryable: matches!(
107                code,
108                RpcErrorCode::RateLimited
109                    | RpcErrorCode::Timeout
110                    | RpcErrorCode::ServiceUnavailable
111                    | RpcErrorCode::BackpressureDropped
112            ),
113            details: None,
114            request_id: "unknown".to_string(),
115            method: None,
116        }
117    }
118
119    pub fn with_context(mut self, request_id: impl Into<String>, method: Option<String>) -> Self {
120        self.request_id = request_id.into();
121        self.method = method;
122        self
123    }
124
125    pub fn with_details(mut self, details: Value) -> Self {
126        self.details = Some(details);
127        self
128    }
129
130    pub fn invalid_request(message: impl Into<String>) -> Self {
131        Self::new(RpcErrorCode::InvalidRequest, message)
132    }
133
134    pub fn method_not_found(method: impl Into<String>) -> Self {
135        let method = method.into();
136        Self::new(
137            RpcErrorCode::MethodNotFound,
138            format!("method '{method}' is not supported by rpc v1"),
139        )
140        .with_details(serde_json::json!({ "method": method }))
141    }
142
143    pub fn invalid_params(message: impl Into<String>) -> Self {
144        Self::new(RpcErrorCode::InvalidParams, message)
145    }
146
147    pub fn unauthorized(message: impl Into<String>) -> Self {
148        Self::new(RpcErrorCode::Unauthorized, message)
149    }
150
151    pub fn forbidden(message: impl Into<String>) -> Self {
152        Self::new(RpcErrorCode::Forbidden, message)
153    }
154
155    pub fn conflict(message: impl Into<String>) -> Self {
156        Self::new(RpcErrorCode::Conflict, message)
157    }
158
159    pub fn not_found(message: impl Into<String>) -> Self {
160        Self::new(RpcErrorCode::NotFound, message)
161    }
162
163    pub fn precondition_failed(message: impl Into<String>) -> Self {
164        Self::new(RpcErrorCode::PreconditionFailed, message)
165    }
166
167    pub fn task_not_found(message: impl Into<String>) -> Self {
168        Self::new(RpcErrorCode::TaskNotFound, message)
169    }
170
171    pub fn loop_not_found(message: impl Into<String>) -> Self {
172        Self::new(RpcErrorCode::LoopNotFound, message)
173    }
174
175    pub fn planning_session_not_found(message: impl Into<String>) -> Self {
176        Self::new(RpcErrorCode::PlanningSessionNotFound, message)
177    }
178
179    pub fn collection_not_found(message: impl Into<String>) -> Self {
180        Self::new(RpcErrorCode::CollectionNotFound, message)
181    }
182
183    pub fn config_invalid(message: impl Into<String>) -> Self {
184        Self::new(RpcErrorCode::ConfigInvalid, message)
185    }
186
187    pub fn idempotency_conflict(message: impl Into<String>) -> Self {
188        Self::new(RpcErrorCode::IdempotencyConflict, message)
189    }
190
191    pub fn service_unavailable(message: impl Into<String>) -> Self {
192        Self::new(RpcErrorCode::ServiceUnavailable, message)
193    }
194
195    pub fn internal(message: impl Into<String>) -> Self {
196        Self::new(RpcErrorCode::Internal, message)
197    }
198
199    pub fn as_body(&self) -> RpcErrorBody {
200        RpcErrorBody {
201            code: self.code.as_str().to_string(),
202            message: self.message.clone(),
203            retryable: self.retryable,
204            details: self.details.clone(),
205        }
206    }
207}
208
209const fn status_for_code(code: RpcErrorCode) -> StatusCode {
210    match code {
211        RpcErrorCode::InvalidRequest | RpcErrorCode::InvalidParams => StatusCode::BAD_REQUEST,
212        RpcErrorCode::MethodNotFound => StatusCode::NOT_FOUND,
213        RpcErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
214        RpcErrorCode::Forbidden => StatusCode::FORBIDDEN,
215        RpcErrorCode::NotFound
216        | RpcErrorCode::TaskNotFound
217        | RpcErrorCode::LoopNotFound
218        | RpcErrorCode::PlanningSessionNotFound
219        | RpcErrorCode::CollectionNotFound => StatusCode::NOT_FOUND,
220        RpcErrorCode::Conflict | RpcErrorCode::IdempotencyConflict => StatusCode::CONFLICT,
221        RpcErrorCode::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
222        RpcErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS,
223        RpcErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
224        RpcErrorCode::ServiceUnavailable | RpcErrorCode::BackpressureDropped => {
225            StatusCode::SERVICE_UNAVAILABLE
226        }
227        RpcErrorCode::ConfigInvalid => StatusCode::BAD_REQUEST,
228        RpcErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
229    }
230}