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 #[error("validation: {0}")]
64 Validation(String),
65 #[error("precondition failed: {0}")]
66 PreconditionFailed(String),
67 /// 5xx — `String` is operator-only detail. Never returned to clients;
68 /// the public message is a fixed canned string per variant.
69 #[error("codec: {0}")]
70 Codec(String),
71 /// Database error with only a stringified detail. Preserved for
72 /// back-compat; new code should prefer `DatabaseTyped` produced by
73 /// `cratestack_sqlx::cool_error_from_sqlx`.
74 #[error("database: {0}")]
75 Database(String),
76 /// Database error with structured information preserved from the driver.
77 ///
78 /// Use [`CoolError::db_sqlstate`] and [`CoolError::db_constraint`] to
79 /// access the typed fields without matching on this variant directly.
80 #[error("database: {}", .0.detail)]
81 DatabaseTyped(DbErrorInfo),
82 #[error("internal: {0}")]
83 Internal(String),
84 /// 503 — the operation cannot proceed right now, but a retry (from
85 /// scratch, not a resume) may succeed later. `String` is the
86 /// public, safe-to-expose message, mirroring the other 4xx-style
87 /// variants above.
88 ///
89 /// Introduced for `@@subscribe` SSE backpressure overflow
90 /// (`docs/design/rpc-transport.md` §3.4/§3.4a: "bounded
91 /// per-subscription send buffer; on overflow, emit
92 /// `Error{code:"unavailable"}`") — the first caller of the RPC
93 /// binding's already-reserved `"unavailable"` code
94 /// (`cratestack-grpc`'s own doc comment already anticipated this:
95 /// "the two the RPC binding never emits today").
96 #[error("unavailable: {0}")]
97 Unavailable(String),
98}
99
100impl CoolError {
101 pub fn code(&self) -> &'static str {
102 match self {
103 Self::BadRequest(_) => "BAD_REQUEST",
104 Self::NotAcceptable(_) => "NOT_ACCEPTABLE",
105 Self::Unauthorized(_) => "UNAUTHORIZED",
106 Self::UnsupportedMediaType(_) => "UNSUPPORTED_MEDIA_TYPE",
107 Self::Forbidden(_) => "FORBIDDEN",
108 Self::NotFound(_) => "NOT_FOUND",
109 Self::Conflict(_) => "CONFLICT",
110 Self::Validation(_) => "VALIDATION_ERROR",
111 Self::PreconditionFailed(_) => "PRECONDITION_FAILED",
112 Self::Codec(_) => "CODEC_ERROR",
113 Self::Database(_) | Self::DatabaseTyped(_) => "DATABASE_ERROR",
114 Self::Internal(_) => "INTERNAL_ERROR",
115 Self::Unavailable(_) => "UNAVAILABLE",
116 }
117 }
118
119 pub fn status_code(&self) -> StatusCode {
120 match self {
121 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
122 Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
123 Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
124 Self::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
125 Self::Forbidden(_) => StatusCode::FORBIDDEN,
126 Self::NotFound(_) => StatusCode::NOT_FOUND,
127 Self::Conflict(_) => StatusCode::CONFLICT,
128 Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
129 Self::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED,
130 Self::Codec(_) => StatusCode::BAD_REQUEST,
131 Self::Database(_) | Self::DatabaseTyped(_) => StatusCode::INTERNAL_SERVER_ERROR,
132 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
133 Self::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
134 }
135 }
136
137 /// Public, safe-to-expose message returned in HTTP responses.
138 ///
139 /// For 4xx variants this is the caller-supplied string. For 5xx variants
140 /// this is a fixed canned message; the caller-supplied string flows to
141 /// `detail` instead and is recorded via tracing only.
142 pub fn public_message(&self) -> Cow<'_, str> {
143 match self {
144 Self::BadRequest(s)
145 | Self::NotAcceptable(s)
146 | Self::Unauthorized(s)
147 | Self::UnsupportedMediaType(s)
148 | Self::Forbidden(s)
149 | Self::NotFound(s)
150 | Self::Conflict(s)
151 | Self::Validation(s)
152 | Self::PreconditionFailed(s)
153 | Self::Unavailable(s) => Cow::Borrowed(s.as_str()),
154 Self::Codec(_) => Cow::Borrowed("invalid request payload"),
155 Self::Database(_) | Self::DatabaseTyped(_) => Cow::Borrowed("internal error"),
156 Self::Internal(_) => Cow::Borrowed("internal error"),
157 }
158 }
159
160 /// Operator-only detail string. For 5xx variants this is the message
161 /// supplied at construction time; for 4xx variants this returns the same
162 /// string as `public_message` (callers are expected to pre-redact 4xx
163 /// messages they emit).
164 pub fn detail(&self) -> Option<&str> {
165 match self {
166 Self::BadRequest(s)
167 | Self::NotAcceptable(s)
168 | Self::Unauthorized(s)
169 | Self::UnsupportedMediaType(s)
170 | Self::Forbidden(s)
171 | Self::NotFound(s)
172 | Self::Conflict(s)
173 | Self::Validation(s)
174 | Self::PreconditionFailed(s)
175 | Self::Codec(s)
176 | Self::Database(s)
177 | Self::Internal(s)
178 | Self::Unavailable(s) => {
179 if s.is_empty() {
180 None
181 } else {
182 Some(s.as_str())
183 }
184 }
185 Self::DatabaseTyped(info) => {
186 if info.detail.is_empty() {
187 None
188 } else {
189 Some(info.detail.as_str())
190 }
191 }
192 }
193 }
194
195 /// Returns the SQLSTATE code if this is a `DatabaseTyped` error with a
196 /// known code (e.g. `"23505"` for unique_violation).
197 ///
198 /// Always returns `None` for the legacy `Database(String)` variant; to
199 /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
200 /// conversion site.
201 pub fn db_sqlstate(&self) -> Option<&str> {
202 match self {
203 Self::DatabaseTyped(info) => info.sqlstate.as_deref(),
204 _ => None,
205 }
206 }
207
208 /// Returns the constraint name if this is a `DatabaseTyped` error that
209 /// carries constraint information (e.g. `"accounts_email_key"`).
210 ///
211 /// Always returns `None` for the legacy `Database(String)` variant; to
212 /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
213 /// conversion site.
214 pub fn db_constraint(&self) -> Option<&str> {
215 match self {
216 Self::DatabaseTyped(info) => info.constraint.as_deref(),
217 _ => None,
218 }
219 }
220
221 pub fn into_response(self) -> CoolErrorResponse {
222 let code = self.code().to_owned();
223 let message = self.public_message().into_owned();
224 CoolErrorResponse {
225 code,
226 message,
227 details: None,
228 }
229 }
230}
231
232pub fn parse_cuid(value: &str) -> Result<String, CoolError> {
233 if is_valid_cuid(value) {
234 Ok(value.to_owned())
235 } else {
236 Err(CoolError::BadRequest(format!(
237 "invalid cuid '{}': expected a lowercase alphanumeric id (2-32 chars)",
238 value,
239 )))
240 }
241}
242
243/// Minimum accepted length for a `Cuid` scalar value.
244///
245/// cuid v1 ids are at least 2 characters (the `'c'` prefix plus at least one
246/// more character); cuid2 ids can be as short as 2 characters too, so this
247/// bound covers both formats.
248const CUID_MIN_LEN: usize = 2;
249
250/// Maximum accepted length for a `Cuid` scalar value.
251///
252/// cuid2 defaults to 24 characters but its length is configurable by the
253/// generator; 32 gives generous headroom above the default while still
254/// rejecting pathological/oversized input.
255const CUID_MAX_LEN: usize = 32;
256
257/// Validates that `value` is a plausible cuid, accepting both the legacy
258/// cuid v1 shape (`'c'`-prefixed) and the current cuid2 shape (no fixed
259/// prefix; the first character is a uniform random lowercase letter).
260///
261/// This is intentionally a format guard, not a full cuid2
262/// checksum/fingerprint verification: lowercase alphanumeric only,
263/// non-empty, bounded length.
264fn is_valid_cuid(value: &str) -> bool {
265 if !(CUID_MIN_LEN..=CUID_MAX_LEN).contains(&value.len()) {
266 return false;
267 }
268 value
269 .chars()
270 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
271}