1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum RmcpServerKitError {
27 #[error("configuration error: {0}")]
29 Config(String),
30
31 #[error("authentication failed: {0}")]
33 Auth(String),
34
35 #[error("authorization denied: {0}")]
37 Rbac(String),
38
39 #[error("rate limited: {0}")]
41 RateLimited(String),
42
43 #[error("rate limited: {message} (retry after {retry_after:?})")]
50 RateLimitedFor {
51 message: String,
53 retry_after: std::time::Duration,
55 },
56
57 #[error("I/O error: {0}")]
59 Io(#[from] std::io::Error),
60
61 #[error("JSON error: {0}")]
63 Json(#[from] serde_json::Error),
64
65 #[error("TOML parse error: {0}")]
67 Toml(#[from] toml::de::Error),
68
69 #[error("TLS error: {0}")]
71 Tls(String),
72
73 #[error("server startup error: {0}")]
75 Startup(String),
76
77 #[cfg(feature = "metrics")]
79 #[error("metrics error: {0}")]
80 Metrics(String),
81}
82
83#[deprecated(
85 since = "3.7.0",
86 note = "renamed to `RmcpServerKitError`; the `mcpx` name predates the crate rename"
87)]
88pub type McpxError = RmcpServerKitError;
89
90fn retry_after_secs(wait: std::time::Duration) -> u64 {
94 let mut secs = wait.as_secs();
95 if wait.subsec_nanos() > 0 {
96 secs = secs.saturating_add(1);
97 }
98 secs.max(1)
99}
100
101impl RmcpServerKitError {
102 #[must_use]
115 pub fn client_message(&self) -> std::borrow::Cow<'_, str> {
116 use std::borrow::Cow;
117 match self {
118 Self::Auth(msg) | Self::Rbac(msg) | Self::RateLimited(msg) => Cow::Borrowed(msg),
119 Self::RateLimitedFor { message, .. } => Cow::Borrowed(message),
120 Self::Config(_)
122 | Self::Io(_)
123 | Self::Json(_)
124 | Self::Toml(_)
125 | Self::Tls(_)
126 | Self::Startup(_) => Cow::Borrowed("internal server error"),
127 #[cfg(feature = "metrics")]
128 Self::Metrics(_) => Cow::Borrowed("internal server error"),
129 }
130 }
131}
132
133impl IntoResponse for RmcpServerKitError {
134 fn into_response(self) -> Response {
135 let (status, client_msg) = match self {
136 Self::Auth(msg) => (StatusCode::UNAUTHORIZED, msg),
137 Self::Rbac(msg) => (StatusCode::FORBIDDEN, msg),
138 Self::RateLimited(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
139 Self::RateLimitedFor {
140 message,
141 retry_after,
142 } => {
143 return (
144 StatusCode::TOO_MANY_REQUESTS,
145 [(
146 axum::http::header::RETRY_AFTER,
147 retry_after_secs(retry_after).to_string(),
148 )],
149 message,
150 )
151 .into_response();
152 }
153 other @ (Self::Config(_)
156 | Self::Io(_)
157 | Self::Json(_)
158 | Self::Toml(_)
159 | Self::Tls(_)
160 | Self::Startup(_)) => {
161 tracing::error!(error = %other, "internal error");
162 (
163 StatusCode::INTERNAL_SERVER_ERROR,
164 "internal server error".into(),
165 )
166 }
167 #[cfg(feature = "metrics")]
168 other @ Self::Metrics(_) => {
169 tracing::error!(error = %other, "internal error");
170 (
171 StatusCode::INTERNAL_SERVER_ERROR,
172 "internal server error".into(),
173 )
174 }
175 };
176 (status, client_msg).into_response()
177 }
178}
179
180pub type Result<T> = std::result::Result<T, RmcpServerKitError>;
182
183#[cfg(test)]
184mod tests {
185 use axum::{http::StatusCode, response::IntoResponse};
186 use http_body_util::BodyExt;
187
188 use super::*;
189
190 async fn status_of(err: RmcpServerKitError) -> (StatusCode, String) {
191 let resp = err.into_response();
192 let status = resp.status();
193 let body = resp.into_body().collect().await.unwrap().to_bytes();
194 (status, String::from_utf8(body.to_vec()).unwrap())
195 }
196
197 #[tokio::test]
198 async fn auth_error_returns_401() {
199 let (status, body) = status_of(RmcpServerKitError::Auth("bad token".into())).await;
200 assert_eq!(status, StatusCode::UNAUTHORIZED);
201 assert!(body.contains("bad token"));
202 }
203
204 #[tokio::test]
205 async fn rbac_error_returns_403() {
206 let (status, body) = status_of(RmcpServerKitError::Rbac("denied".into())).await;
207 assert_eq!(status, StatusCode::FORBIDDEN);
208 assert!(body.contains("denied"));
209 }
210
211 #[tokio::test]
212 async fn rate_limited_error_returns_429() {
213 let (status, body) = status_of(RmcpServerKitError::RateLimited("slow down".into())).await;
214 assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
215 assert!(body.contains("slow down"));
216 }
217
218 #[tokio::test]
219 async fn legacy_rate_limited_has_no_retry_after_header() {
220 let resp = RmcpServerKitError::RateLimited("slow down".into()).into_response();
221 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
222 assert!(
223 !resp.headers().contains_key(axum::http::header::RETRY_AFTER),
224 "legacy variant must stay headerless"
225 );
226 }
227
228 #[tokio::test]
229 async fn rate_limited_for_sets_retry_after_header() {
230 let resp = RmcpServerKitError::RateLimitedFor {
231 message: "slow down".into(),
232 retry_after: std::time::Duration::from_millis(1500),
233 }
234 .into_response();
235 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
236 let header = resp
237 .headers()
238 .get(axum::http::header::RETRY_AFTER)
239 .expect("Retry-After present")
240 .to_str()
241 .unwrap()
242 .to_owned();
243 assert_eq!(header, "2", "1.5s must round UP to 2");
244 let body = resp.into_body().collect().await.unwrap().to_bytes();
245 assert_eq!(body.as_ref(), b"slow down");
246 }
247
248 #[test]
249 fn retry_after_secs_rounds_up_and_never_zero() {
250 use std::time::Duration;
251 assert_eq!(retry_after_secs(Duration::ZERO), 1, "zero floors to 1");
252 assert_eq!(retry_after_secs(Duration::from_millis(1)), 1);
253 assert_eq!(retry_after_secs(Duration::from_millis(999)), 1);
254 assert_eq!(retry_after_secs(Duration::from_secs(1)), 1, "exact stays");
255 assert_eq!(retry_after_secs(Duration::from_millis(1001)), 2, "ceil");
256 assert_eq!(retry_after_secs(Duration::from_secs(60)), 60);
257 }
258
259 #[tokio::test]
260 async fn config_error_returns_500() {
261 let (status, body) = status_of(RmcpServerKitError::Config("bad".into())).await;
262 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
263 assert_eq!(
264 body, "internal server error",
265 "must not leak internal detail"
266 );
267 }
268
269 #[tokio::test]
270 async fn io_error_returns_500() {
271 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
272 let (status, body) = status_of(RmcpServerKitError::from(io_err)).await;
273 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
274 assert_eq!(
275 body, "internal server error",
276 "must not leak internal detail"
277 );
278 }
279
280 #[tokio::test]
281 async fn tls_error_returns_500() {
282 let (status, body) = status_of(RmcpServerKitError::Tls("bad cert".into())).await;
283 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
284 assert_eq!(
285 body, "internal server error",
286 "must not leak internal detail"
287 );
288 }
289
290 #[tokio::test]
291 async fn startup_error_returns_500() {
292 let (status, body) = status_of(RmcpServerKitError::Startup("bind failed".into())).await;
293 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
294 assert_eq!(
295 body, "internal server error",
296 "must not leak internal detail"
297 );
298 }
299
300 #[cfg(feature = "metrics")]
301 #[tokio::test]
302 async fn metrics_error_returns_500() {
303 let (status, body) = status_of(RmcpServerKitError::Metrics("dup metric".into())).await;
304 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
305 assert_eq!(
306 body, "internal server error",
307 "must not leak internal detail"
308 );
309 }
310
311 #[test]
312 fn display_preserves_message() {
313 let err = RmcpServerKitError::Auth("unauthorized".into());
314 assert_eq!(err.to_string(), "authentication failed: unauthorized");
315
316 let err = RmcpServerKitError::Rbac("forbidden".into());
317 assert_eq!(err.to_string(), "authorization denied: forbidden");
318
319 let err = RmcpServerKitError::RateLimited("throttled".into());
320 assert_eq!(err.to_string(), "rate limited: throttled");
321 }
322
323 #[test]
324 fn client_message_exposes_client_facing_text_and_hides_internal_detail() {
325 assert_eq!(
327 RmcpServerKitError::Auth("bad token".into()).client_message(),
328 "bad token"
329 );
330 assert_eq!(
331 RmcpServerKitError::Rbac("nope".into()).client_message(),
332 "nope"
333 );
334 assert_eq!(
335 RmcpServerKitError::RateLimited("slow down".into()).client_message(),
336 "slow down"
337 );
338 assert_eq!(
339 RmcpServerKitError::RateLimitedFor {
340 message: "too many".into(),
341 retry_after: std::time::Duration::from_secs(1),
342 }
343 .client_message(),
344 "too many"
345 );
346
347 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "secret/path/leak");
349 assert_eq!(
350 RmcpServerKitError::from(io_err).client_message(),
351 "internal server error"
352 );
353 assert_eq!(
354 RmcpServerKitError::Tls("private key /etc/certs/server.key".into()).client_message(),
355 "internal server error"
356 );
357 assert_eq!(
358 RmcpServerKitError::Config("bind 10.0.0.5:8443 failed".into()).client_message(),
359 "internal server error"
360 );
361 }
362
363 #[tokio::test]
364 async fn client_message_matches_into_response_body() {
365 for err in [
367 RmcpServerKitError::Auth("a".into()),
368 RmcpServerKitError::Rbac("b".into()),
369 RmcpServerKitError::RateLimited("c".into()),
370 RmcpServerKitError::Config("d".into()),
371 RmcpServerKitError::Tls("e".into()),
372 ] {
373 let expected = err.client_message().into_owned();
374 let (_status, body) = status_of(err).await;
375 assert_eq!(body, expected, "client_message must equal the wire body");
376 }
377 }
378}