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}