Skip to main content

taut_rpc/
error.rs

1//! Error contract for taut-rpc procedures. See SPEC §3.3.
2//!
3//! A `#[rpc]` function returns `Result<T, E>` where `E: TautError`. On the wire,
4//! errors are serialized per SPEC §4.1 as:
5//!
6//! ```json
7//! { "err": { "code": "...", "payload": ... } }
8//! ```
9//!
10//! Implementations of [`TautError`] supply a stable string [`code`](TautError::code)
11//! per variant and a `Serialize` payload. The HTTP status code is also chosen by
12//! the implementation (default `400`).
13//!
14//! # TODO (ROADMAP Phase 2)
15//!
16//! A `#[derive(taut_rpc_macros::TautError)]` derive macro will be provided so users
17//! can write:
18//!
19//! ```ignore
20//! #[derive(taut_rpc_macros::TautError, serde::Serialize)]
21//! #[serde(tag = "code", content = "payload", rename_all = "snake_case")]
22//! enum MyError {
23//!     #[taut(http = 404)]
24//!     NotFound,
25//!     #[taut(http = 409)]
26//!     Conflict { detail: String },
27//! }
28//! ```
29//!
30//! and have it expand to an impl equivalent to the hand-written one for
31//! [`StandardError`] in this module. The derive does not yet exist.
32
33use serde::Serialize;
34
35use crate::validate::ValidationError;
36
37/// Procedure-level error type. Implementations give every variant a stable string `code`
38/// and a `Serialize` payload that ends up in the JSON wire format as
39/// `{ "err": { "code": "...", "payload": ... } }`.
40///
41/// # Examples
42///
43/// The recommended way to define a domain-specific error is via the
44/// `#[derive(taut_rpc::TautError)]` macro:
45///
46/// ```rust,ignore
47/// use taut_rpc::TautError;
48///
49/// #[derive(serde::Serialize, taut_rpc::TautError, Debug)]
50/// #[serde(tag = "code", content = "payload", rename_all = "snake_case")]
51/// pub enum AddError {
52///     #[taut(status = 400)]
53///     Overflow,
54/// }
55/// ```
56///
57/// A hand-written impl looks the same as what the derive expands to —
58/// match each variant to its stable `code` and HTTP status:
59///
60/// ```rust,ignore
61/// use taut_rpc::TautError;
62///
63/// #[derive(serde::Serialize)]
64/// #[serde(tag = "code", content = "payload", rename_all = "snake_case")]
65/// pub enum AddError { Overflow }
66///
67/// impl TautError for AddError {
68///     fn code(&self) -> &'static str { match self { Self::Overflow => "overflow" } }
69///     fn http_status(&self) -> u16 { 400 }
70/// }
71/// ```
72///
73/// For errors that map cleanly onto common HTTP semantics, prefer the built-in
74/// [`StandardError`] taxonomy.
75pub trait TautError: Serialize + Sized {
76    /// Stable, machine-readable code. SHOULD be lowercase `snake_case`.
77    fn code(&self) -> &'static str;
78
79    /// HTTP status code this error maps to. Default `400`.
80    fn http_status(&self) -> u16 {
81        400
82    }
83}
84
85/// Built-in standard errors. Procedures may use these directly or wrap them.
86///
87/// This is a curated set of "common" RPC errors that map cleanly onto well-known
88/// HTTP status codes. The full taxonomy is:
89///
90/// | Variant                | Code                    | HTTP |
91/// |------------------------|-------------------------|------|
92/// | `BadRequest`           | `bad_request`           | 400  |
93/// | `ValidationFailed`     | `validation_error`      | 400  |
94/// | `Unauthenticated`      | `unauthenticated`       | 401  |
95/// | `Forbidden`            | `forbidden`             | 403  |
96/// | `NotFound`             | `not_found`             | 404  |
97/// | `Conflict`             | `conflict`              | 409  |
98/// | `UnprocessableEntity`  | `unprocessable_entity`  | 422  |
99/// | `RateLimited`          | `rate_limited`          | 429  |
100/// | `Internal`             | `internal`              | 500  |
101/// | `ServiceUnavailable`   | `service_unavailable`   | 503  |
102/// | `Timeout`              | `timeout`               | 504  |
103///
104/// Note: `ValidationFailed` carries a list of [`ValidationError`] entries and
105/// is emitted by the server router when input validation rejects a request
106/// before the procedure runs. Its discriminant is `validation_error` (not
107/// `validation_failed`) to match the wire contract.
108///
109/// # Design principle
110///
111/// `StandardError` is intentionally narrow: it covers the cross-cutting concerns
112/// every RPC stack tends to hit (auth, rate limiting, transport-shaped failures)
113/// and nothing else. Anything domain-specific — business-rule violations,
114/// per-procedure failure modes, structured validation results — should be its
115/// own error enum with `#[derive(taut_rpc::TautError)]`. Reaching for
116/// `StandardError` to model domain errors collapses meaningful distinctions
117/// into a single bucket and is an anti-pattern.
118///
119/// Per SPEC §8 the `unauthenticated` discriminant is reserved.
120#[derive(Debug, Clone, Serialize, thiserror::Error)]
121#[serde(tag = "code", content = "payload", rename_all = "snake_case")]
122pub enum StandardError {
123    /// 400 — Malformed or syntactically invalid request.
124    #[error("bad request: {message}")]
125    BadRequest {
126        /// Human-readable description of why the request was rejected.
127        message: String,
128    },
129    /// 400 — Server-side input validation rejected the request before the
130    /// procedure ran. Carries the per-field failures that the validator
131    /// collected. Serializes with the `validation_error` discriminant.
132    #[error("validation failed")]
133    #[serde(rename = "validation_error")]
134    ValidationFailed {
135        /// Per-field validation failures collected by the validator.
136        errors: Vec<ValidationError>,
137    },
138    /// 401 — Caller is not authenticated.
139    #[error("unauthenticated")]
140    Unauthenticated,
141    /// 403 — Caller is authenticated but not permitted.
142    #[error("forbidden: {reason}")]
143    Forbidden {
144        /// Human-readable explanation of why the caller was denied.
145        reason: String,
146    },
147    /// 404 — Target resource does not exist.
148    #[error("not found")]
149    NotFound,
150    /// 409 — State conflict (e.g. unique-key violation, optimistic-lock failure).
151    #[error("conflict: {message}")]
152    Conflict {
153        /// Human-readable description of the conflict.
154        message: String,
155    },
156    /// 422 — Request was syntactically valid but failed semantic validation.
157    #[error("unprocessable entity: {message}")]
158    UnprocessableEntity {
159        /// Human-readable description of the semantic failure.
160        message: String,
161    },
162    /// 429 — Caller is being rate limited.
163    #[error("rate limited (retry after {retry_after_seconds}s)")]
164    RateLimited {
165        /// Suggested delay before the caller retries, in seconds.
166        retry_after_seconds: u32,
167    },
168    /// 500 — Unexpected server-side failure.
169    #[error("internal error")]
170    Internal,
171    /// 503 — Service is temporarily unavailable (graceful degradation, deploys, etc.).
172    #[error("service unavailable (retry after {retry_after_seconds}s)")]
173    ServiceUnavailable {
174        /// Suggested delay before the caller retries, in seconds.
175        retry_after_seconds: u32,
176    },
177    /// 504 — Upstream or internal operation timed out.
178    #[error("timeout")]
179    Timeout,
180}
181
182impl TautError for StandardError {
183    fn code(&self) -> &'static str {
184        match self {
185            Self::BadRequest { .. } => "bad_request",
186            Self::ValidationFailed { .. } => "validation_error",
187            Self::Unauthenticated => "unauthenticated",
188            Self::Forbidden { .. } => "forbidden",
189            Self::NotFound => "not_found",
190            Self::Conflict { .. } => "conflict",
191            Self::UnprocessableEntity { .. } => "unprocessable_entity",
192            Self::RateLimited { .. } => "rate_limited",
193            Self::Internal => "internal",
194            Self::ServiceUnavailable { .. } => "service_unavailable",
195            Self::Timeout => "timeout",
196        }
197    }
198
199    #[allow(clippy::match_same_arms)] // arms kept distinct for variant-to-status traceability
200    fn http_status(&self) -> u16 {
201        match self {
202            Self::BadRequest { .. } => 400,
203            Self::ValidationFailed { .. } => 400,
204            Self::Unauthenticated => 401,
205            Self::Forbidden { .. } => 403,
206            Self::NotFound => 404,
207            Self::Conflict { .. } => 409,
208            Self::UnprocessableEntity { .. } => 422,
209            Self::RateLimited { .. } => 429,
210            Self::Internal => 500,
211            Self::ServiceUnavailable { .. } => 503,
212            Self::Timeout => 504,
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn code_unauthenticated() {
223        assert_eq!(StandardError::Unauthenticated.code(), "unauthenticated");
224    }
225
226    #[test]
227    fn code_forbidden() {
228        assert_eq!(
229            StandardError::Forbidden { reason: "x".into() }.code(),
230            "forbidden"
231        );
232    }
233
234    #[test]
235    fn code_not_found() {
236        assert_eq!(StandardError::NotFound.code(), "not_found");
237    }
238
239    #[test]
240    fn code_rate_limited() {
241        assert_eq!(
242            StandardError::RateLimited {
243                retry_after_seconds: 5
244            }
245            .code(),
246            "rate_limited"
247        );
248    }
249
250    #[test]
251    fn code_internal() {
252        assert_eq!(StandardError::Internal.code(), "internal");
253    }
254
255    #[test]
256    fn http_status_unauthenticated() {
257        assert_eq!(StandardError::Unauthenticated.http_status(), 401);
258    }
259
260    #[test]
261    fn http_status_forbidden() {
262        assert_eq!(
263            StandardError::Forbidden { reason: "x".into() }.http_status(),
264            403
265        );
266    }
267
268    #[test]
269    fn http_status_not_found() {
270        assert_eq!(StandardError::NotFound.http_status(), 404);
271    }
272
273    #[test]
274    fn http_status_rate_limited() {
275        assert_eq!(
276            StandardError::RateLimited {
277                retry_after_seconds: 5
278            }
279            .http_status(),
280            429
281        );
282    }
283
284    #[test]
285    fn http_status_internal() {
286        assert_eq!(StandardError::Internal.http_status(), 500);
287    }
288
289    #[test]
290    fn serialize_forbidden_contains_code_and_payload() {
291        let err = StandardError::Forbidden {
292            reason: "test".into(),
293        };
294        let json = serde_json::to_string(&err).expect("serialize StandardError");
295        assert!(
296            json.contains("\"code\":\"forbidden\""),
297            "expected code field in {json}"
298        );
299        assert!(
300            json.contains("\"reason\":\"test\""),
301            "expected payload reason in {json}"
302        );
303    }
304
305    #[test]
306    fn code_bad_request() {
307        assert_eq!(
308            StandardError::BadRequest {
309                message: "x".into()
310            }
311            .code(),
312            "bad_request"
313        );
314    }
315
316    #[test]
317    fn code_conflict() {
318        assert_eq!(
319            StandardError::Conflict {
320                message: "x".into()
321            }
322            .code(),
323            "conflict"
324        );
325    }
326
327    #[test]
328    fn code_unprocessable_entity() {
329        assert_eq!(
330            StandardError::UnprocessableEntity {
331                message: "x".into()
332            }
333            .code(),
334            "unprocessable_entity"
335        );
336    }
337
338    #[test]
339    fn code_service_unavailable() {
340        assert_eq!(
341            StandardError::ServiceUnavailable {
342                retry_after_seconds: 5
343            }
344            .code(),
345            "service_unavailable"
346        );
347    }
348
349    #[test]
350    fn code_timeout() {
351        assert_eq!(StandardError::Timeout.code(), "timeout");
352    }
353
354    #[test]
355    fn http_status_bad_request() {
356        assert_eq!(
357            StandardError::BadRequest {
358                message: "x".into()
359            }
360            .http_status(),
361            400
362        );
363    }
364
365    #[test]
366    fn http_status_conflict() {
367        assert_eq!(
368            StandardError::Conflict {
369                message: "x".into()
370            }
371            .http_status(),
372            409
373        );
374    }
375
376    #[test]
377    fn http_status_unprocessable_entity() {
378        assert_eq!(
379            StandardError::UnprocessableEntity {
380                message: "x".into()
381            }
382            .http_status(),
383            422
384        );
385    }
386
387    #[test]
388    fn http_status_service_unavailable() {
389        assert_eq!(
390            StandardError::ServiceUnavailable {
391                retry_after_seconds: 5
392            }
393            .http_status(),
394            503
395        );
396    }
397
398    #[test]
399    fn http_status_timeout() {
400        assert_eq!(StandardError::Timeout.http_status(), 504);
401    }
402
403    #[test]
404    fn serialize_bad_request_contains_code_and_message() {
405        let err = StandardError::BadRequest {
406            message: "x".into(),
407        };
408        let json = serde_json::to_string(&err).expect("serialize StandardError");
409        assert!(
410            json.contains("\"code\":\"bad_request\""),
411            "expected code field in {json}"
412        );
413        assert!(
414            json.contains("\"message\":\"x\""),
415            "expected payload message in {json}"
416        );
417    }
418
419    #[test]
420    fn code_validation_failed() {
421        assert_eq!(
422            StandardError::ValidationFailed { errors: vec![] }.code(),
423            "validation_error"
424        );
425    }
426
427    #[test]
428    fn http_status_validation_failed() {
429        assert_eq!(
430            StandardError::ValidationFailed { errors: vec![] }.http_status(),
431            400
432        );
433    }
434
435    #[test]
436    fn serialize_validation_failed_with_errors() {
437        let err = StandardError::ValidationFailed {
438            errors: vec![ValidationError {
439                path: "name".into(),
440                constraint: "length".into(),
441                message: "too short".into(),
442            }],
443        };
444        let json = serde_json::to_string(&err).expect("serialize StandardError");
445        assert!(
446            json.contains("\"code\":\"validation_error\""),
447            "expected code field in {json}"
448        );
449        assert!(
450            json.contains("\"errors\":[{"),
451            "expected errors array in {json}"
452        );
453        assert!(
454            json.contains("\"path\":\"name\""),
455            "expected path in {json}"
456        );
457        assert!(
458            json.contains("\"constraint\":\"length\""),
459            "expected constraint in {json}"
460        );
461        assert!(
462            json.contains("\"message\":\"too short\""),
463            "expected message in {json}"
464        );
465    }
466}