Skip to main content

rmcp_server_kit/
error.rs

1use axum::{
2    http::StatusCode,
3    response::{IntoResponse, Response},
4};
5use thiserror::Error;
6
7/// Generic MCP server error type.
8///
9/// Application crates should define their own error types and convert
10/// from/into `RmcpServerKitError` where needed.
11///
12/// # Client-facing message invariant
13///
14/// The `String` payloads of [`Auth`](Self::Auth), [`Rbac`](Self::Rbac),
15/// [`RateLimited`](Self::RateLimited), and the `message` field of
16/// [`RateLimitedFor`](Self::RateLimitedFor) are rendered **verbatim to the
17/// HTTP client** by [`IntoResponse`]. Construction sites MUST keep these
18/// client-safe: no internal error text, source-error chains, file paths, IPs,
19/// SQL, or dependency details. Internal-only variants (`Config`, `Io`, `Json`,
20/// `Toml`, `Tls`, `Startup`, `Metrics`) are collapsed to a generic
21/// `"internal server error"` body and their detail is logged server-side only.
22/// Use [`client_message`](Self::client_message) to obtain the exact body that
23/// will be sent to the client for any variant.
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum RmcpServerKitError {
27    /// Configuration parsing or validation failed.
28    #[error("configuration error: {0}")]
29    Config(String),
30
31    /// Authentication failed (bad/missing credential).
32    #[error("authentication failed: {0}")]
33    Auth(String),
34
35    /// Authorization (RBAC) denied the request.
36    #[error("authorization denied: {0}")]
37    Rbac(String),
38
39    /// Request was rejected by a rate limiter.
40    #[error("rate limited: {0}")]
41    RateLimited(String),
42
43    /// Request was rejected by a rate limiter that knows the wait time.
44    ///
45    /// Renders as HTTP 429 with a `Retry-After` header (RFC 9110
46    /// delta-seconds: the duration is rounded **up** to whole seconds,
47    /// minimum `1`) and the message as a plain-text body. The legacy
48    /// [`RateLimited`](Self::RateLimited) variant remains headerless.
49    #[error("rate limited: {message} (retry after {retry_after:?})")]
50    RateLimitedFor {
51        /// Plain-text client-facing message (response body).
52        message: String,
53        /// Best-effort wait until the next request could be admitted.
54        retry_after: std::time::Duration,
55    },
56
57    /// Underlying I/O error.
58    #[error("I/O error: {0}")]
59    Io(#[from] std::io::Error),
60
61    /// JSON (de)serialization error.
62    #[error("JSON error: {0}")]
63    Json(#[from] serde_json::Error),
64
65    /// TOML parse error (configuration loading).
66    #[error("TOML parse error: {0}")]
67    Toml(#[from] toml::de::Error),
68
69    /// TLS configuration failure (certificate load, key parse, rustls config).
70    #[error("TLS error: {0}")]
71    Tls(String),
72
73    /// Server startup failure (binding, listener, runtime initialization).
74    #[error("server startup error: {0}")]
75    Startup(String),
76
77    /// Metrics registration failure (e.g. Prometheus duplicate or invalid metric).
78    #[cfg(feature = "metrics")]
79    #[error("metrics error: {0}")]
80    Metrics(String),
81}
82
83/// Deprecated compatibility alias for the pre-rename public error type.
84#[deprecated(
85    since = "3.7.0",
86    note = "renamed to `RmcpServerKitError`; the `mcpx` name predates the crate rename"
87)]
88pub type McpxError = RmcpServerKitError;
89
90/// Render a wait [`Duration`](std::time::Duration) as RFC 9110
91/// `Retry-After` delta-seconds: rounded **up** to whole seconds, never
92/// below `1` (a `0` would invite an immediate retry storm).
93fn 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    /// The exact body this error sends to the HTTP client.
103    ///
104    /// Client-facing variants ([`Auth`](Self::Auth), [`Rbac`](Self::Rbac),
105    /// [`RateLimited`](Self::RateLimited), [`RateLimitedFor`](Self::RateLimitedFor))
106    /// return their message verbatim; all internal variants return the generic
107    /// `"internal server error"` so implementation detail never leaks on the
108    /// wire. This is the single source of truth for the client body — the
109    /// [`IntoResponse`] impl uses it — so callers can assert or reuse the
110    /// client-safe text without duplicating the mapping.
111    ///
112    /// See the type-level "Client-facing message invariant" for the contract
113    /// construction sites must uphold.
114    #[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            // Internal variants: never leak detail to the client.
121            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            // All remaining variants are internal - return a generic 500
154            // to avoid leaking implementation details.
155            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
180/// Convenience `Result` alias bound to [`RmcpServerKitError`].
181pub 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        // Client-facing variants: message passes through verbatim.
326        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        // Internal variants: detail is hidden behind a generic body.
348        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        // The accessor and the wire body must agree for every variant we test.
366        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}