Skip to main content

cratestack_core/
error.rs

1//! `CoolError` — the framework's error type, its 4xx/5xx HTTP mapping,
2//! and the public response envelope clients see on failure.
3//!
4//! 4xx variants carry caller-visible messages; 5xx variants keep the
5//! operator detail off the wire and return a canned public message
6//! while preserving the original string for `tracing` / `detail()`.
7
8use std::borrow::Cow;
9
10use http::StatusCode;
11use serde::{Deserialize, Serialize};
12
13use crate::value::Value;
14
15#[cfg(test)]
16mod tests;
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct CoolErrorResponse {
20    pub code: String,
21    pub message: String,
22    pub details: Option<Value>,
23}
24
25/// Structured information extracted from a driver-level database error.
26///
27/// Produced by `cratestack-sqlx`'s [`cool_error_from_sqlx`] when the
28/// underlying `sqlx::Error` carries a typed `DatabaseError` (e.g.
29/// `PgDatabaseError`). Consumers can inspect `constraint` and `code` without
30/// substring-matching the stringified error message.
31///
32/// [`cool_error_from_sqlx`]: cratestack_sqlx::cool_error_from_sqlx
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
34pub struct DbErrorInfo {
35    /// The operator-visible detail string (equivalent to `error.to_string()`).
36    pub detail: String,
37    /// The five-character SQLSTATE code (`"23505"` for unique_violation, etc.).
38    /// `None` when the driver did not surface a code.
39    pub sqlstate: Option<String>,
40    /// The constraint name reported by the database (`"accounts_email_key"`,
41    /// etc.). `None` when the error is not constraint-related.
42    pub constraint: Option<String>,
43}
44
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum CoolError {
48    /// 4xx — `String` is the public message returned to the client.
49    #[error("bad request: {0}")]
50    BadRequest(String),
51    #[error("not acceptable: {0}")]
52    NotAcceptable(String),
53    #[error("unauthorized: {0}")]
54    Unauthorized(String),
55    #[error("unsupported media type: {0}")]
56    UnsupportedMediaType(String),
57    #[error("forbidden: {0}")]
58    Forbidden(String),
59    #[error("not found: {0}")]
60    NotFound(String),
61    #[error("conflict: {0}")]
62    Conflict(String),
63    /// Conflict (409) with structured database information preserved from
64    /// the driver — e.g. a unique-constraint violation. Prefer this over
65    /// `Conflict(String)` when the conflict originates from a database
66    /// error so [`CoolError::db_sqlstate`] / [`CoolError::db_constraint`]
67    /// keep working for callers that inspect the typed fields regardless of
68    /// whether the error surfaced as a 500 (`DatabaseTyped`) or a 409
69    /// (`ConflictTyped`).
70    #[error("conflict: {}", .0.detail)]
71    ConflictTyped(DbErrorInfo),
72    #[error("validation: {0}")]
73    Validation(String),
74    #[error("precondition failed: {0}")]
75    PreconditionFailed(String),
76    /// 5xx — `String` is operator-only detail. Never returned to clients;
77    /// the public message is a fixed canned string per variant.
78    #[error("codec: {0}")]
79    Codec(String),
80    /// Database error with only a stringified detail. Preserved for
81    /// back-compat; new code should prefer `DatabaseTyped` produced by
82    /// `cratestack_sqlx::cool_error_from_sqlx`.
83    #[error("database: {0}")]
84    Database(String),
85    /// Database error with structured information preserved from the driver.
86    ///
87    /// Use [`CoolError::db_sqlstate`] and [`CoolError::db_constraint`] to
88    /// access the typed fields without matching on this variant directly.
89    #[error("database: {}", .0.detail)]
90    DatabaseTyped(DbErrorInfo),
91    #[error("internal: {0}")]
92    Internal(String),
93    /// 503 — the operation cannot proceed right now, but a retry (from
94    /// scratch, not a resume) may succeed later. `String` is the
95    /// public, safe-to-expose message, mirroring the other 4xx-style
96    /// variants above.
97    ///
98    /// Introduced for `@@subscribe` SSE backpressure overflow
99    /// (`docs/design/rpc-transport.md` §3.4/§3.4a: "bounded
100    /// per-subscription send buffer; on overflow, emit
101    /// `Error{code:"unavailable"}`") — the first caller of the RPC
102    /// binding's already-reserved `"unavailable"` code
103    /// (`cratestack-grpc`'s own doc comment already anticipated this:
104    /// "the two the RPC binding never emits today").
105    #[error("unavailable: {0}")]
106    Unavailable(String),
107}
108
109impl CoolError {
110    pub fn code(&self) -> &'static str {
111        match self {
112            Self::BadRequest(_) => "BAD_REQUEST",
113            Self::NotAcceptable(_) => "NOT_ACCEPTABLE",
114            Self::Unauthorized(_) => "UNAUTHORIZED",
115            Self::UnsupportedMediaType(_) => "UNSUPPORTED_MEDIA_TYPE",
116            Self::Forbidden(_) => "FORBIDDEN",
117            Self::NotFound(_) => "NOT_FOUND",
118            Self::Conflict(_) | Self::ConflictTyped(_) => "CONFLICT",
119            Self::Validation(_) => "VALIDATION_ERROR",
120            Self::PreconditionFailed(_) => "PRECONDITION_FAILED",
121            Self::Codec(_) => "CODEC_ERROR",
122            Self::Database(_) | Self::DatabaseTyped(_) => "DATABASE_ERROR",
123            Self::Internal(_) => "INTERNAL_ERROR",
124            Self::Unavailable(_) => "UNAVAILABLE",
125        }
126    }
127
128    pub fn status_code(&self) -> StatusCode {
129        match self {
130            Self::BadRequest(_) => StatusCode::BAD_REQUEST,
131            Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
132            Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
133            Self::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
134            Self::Forbidden(_) => StatusCode::FORBIDDEN,
135            Self::NotFound(_) => StatusCode::NOT_FOUND,
136            Self::Conflict(_) | Self::ConflictTyped(_) => StatusCode::CONFLICT,
137            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
138            Self::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED,
139            Self::Codec(_) => StatusCode::BAD_REQUEST,
140            Self::Database(_) | Self::DatabaseTyped(_) => StatusCode::INTERNAL_SERVER_ERROR,
141            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
142            Self::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
143        }
144    }
145
146    /// Public, safe-to-expose message returned in HTTP responses.
147    ///
148    /// For 4xx variants this is the caller-supplied string. For 5xx variants
149    /// this is a fixed canned message; the caller-supplied string flows to
150    /// `detail` instead and is recorded via tracing only.
151    pub fn public_message(&self) -> Cow<'_, str> {
152        match self {
153            Self::BadRequest(s)
154            | Self::NotAcceptable(s)
155            | Self::Unauthorized(s)
156            | Self::UnsupportedMediaType(s)
157            | Self::Forbidden(s)
158            | Self::NotFound(s)
159            | Self::Conflict(s)
160            | Self::Validation(s)
161            | Self::PreconditionFailed(s)
162            | Self::Unavailable(s) => Cow::Borrowed(s.as_str()),
163            Self::Codec(_) => Cow::Borrowed("invalid request payload"),
164            Self::Database(_) | Self::DatabaseTyped(_) => Cow::Borrowed("internal error"),
165            Self::Internal(_) => Cow::Borrowed("internal error"),
166            // 4xx, like `Conflict(String)` — the driver's message is
167            // caller-visible (matches the pre-existing behaviour of
168            // `classify_unique_violation`, which built `Conflict` from
169            // `db_err.message()`).
170            Self::ConflictTyped(info) => Cow::Borrowed(info.detail.as_str()),
171        }
172    }
173
174    /// Operator-only detail string. For 5xx variants this is the message
175    /// supplied at construction time; for 4xx variants this returns the same
176    /// string as `public_message` (callers are expected to pre-redact 4xx
177    /// messages they emit).
178    pub fn detail(&self) -> Option<&str> {
179        match self {
180            Self::BadRequest(s)
181            | Self::NotAcceptable(s)
182            | Self::Unauthorized(s)
183            | Self::UnsupportedMediaType(s)
184            | Self::Forbidden(s)
185            | Self::NotFound(s)
186            | Self::Conflict(s)
187            | Self::Validation(s)
188            | Self::PreconditionFailed(s)
189            | Self::Codec(s)
190            | Self::Database(s)
191            | Self::Internal(s)
192            | Self::Unavailable(s) => {
193                if s.is_empty() {
194                    None
195                } else {
196                    Some(s.as_str())
197                }
198            }
199            Self::DatabaseTyped(info) | Self::ConflictTyped(info) => {
200                if info.detail.is_empty() {
201                    None
202                } else {
203                    Some(info.detail.as_str())
204                }
205            }
206        }
207    }
208
209    /// Returns the SQLSTATE code if this is a `DatabaseTyped` error with a
210    /// known code (e.g. `"23505"` for unique_violation).
211    ///
212    /// Always returns `None` for the legacy `Database(String)` variant; to
213    /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
214    /// conversion site.
215    pub fn db_sqlstate(&self) -> Option<&str> {
216        match self {
217            Self::DatabaseTyped(info) | Self::ConflictTyped(info) => info.sqlstate.as_deref(),
218            _ => None,
219        }
220    }
221
222    /// Returns the constraint name if this is a `DatabaseTyped` error that
223    /// carries constraint information (e.g. `"accounts_email_key"`).
224    ///
225    /// Always returns `None` for the legacy `Database(String)` variant; to
226    /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
227    /// conversion site.
228    pub fn db_constraint(&self) -> Option<&str> {
229        match self {
230            Self::DatabaseTyped(info) | Self::ConflictTyped(info) => info.constraint.as_deref(),
231            _ => None,
232        }
233    }
234
235    pub fn into_response(self) -> CoolErrorResponse {
236        let code = self.code().to_owned();
237        let message = self.public_message().into_owned();
238        CoolErrorResponse {
239            code,
240            message,
241            details: None,
242        }
243    }
244}
245
246pub fn parse_cuid(value: &str) -> Result<String, CoolError> {
247    if is_valid_cuid(value) {
248        Ok(value.to_owned())
249    } else {
250        Err(CoolError::BadRequest(format!(
251            "invalid cuid '{}': expected a lowercase alphanumeric id (2-32 chars)",
252            value,
253        )))
254    }
255}
256
257/// Minimum accepted length for a `Cuid` scalar value.
258///
259/// cuid v1 ids are at least 2 characters (the `'c'` prefix plus at least one
260/// more character); cuid2 ids can be as short as 2 characters too, so this
261/// bound covers both formats.
262const CUID_MIN_LEN: usize = 2;
263
264/// Maximum accepted length for a `Cuid` scalar value.
265///
266/// cuid2 defaults to 24 characters but its length is configurable by the
267/// generator; 32 gives generous headroom above the default while still
268/// rejecting pathological/oversized input.
269const CUID_MAX_LEN: usize = 32;
270
271/// Validates that `value` is a plausible cuid, accepting both the legacy
272/// cuid v1 shape (`'c'`-prefixed) and the current cuid2 shape (no fixed
273/// prefix; the first character is a uniform random lowercase letter).
274///
275/// This is intentionally a format guard, not a full cuid2
276/// checksum/fingerprint verification: lowercase alphanumeric only,
277/// non-empty, bounded length.
278fn is_valid_cuid(value: &str) -> bool {
279    if !(CUID_MIN_LEN..=CUID_MAX_LEN).contains(&value.len()) {
280        return false;
281    }
282    value
283        .chars()
284        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
285}