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}