Skip to main content

runledger_postgres/
error.rs

1use std::{fmt, sync::Arc};
2
3use sqlx::error::ErrorKind;
4
5mod classify;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum QueryErrorCategory {
9    Conflict,
10    Validation,
11    Forbidden,
12    Internal,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct FrameworkConstraintSpec {
17    category: QueryErrorCategory,
18    code: &'static str,
19    client_message: &'static str,
20}
21
22impl FrameworkConstraintSpec {
23    #[must_use]
24    pub const fn new(
25        category: QueryErrorCategory,
26        code: &'static str,
27        client_message: &'static str,
28    ) -> Self {
29        Self {
30            category,
31            code,
32            client_message,
33        }
34    }
35
36    #[must_use]
37    pub const fn category(&self) -> QueryErrorCategory {
38        self.category
39    }
40
41    #[must_use]
42    pub const fn code(&self) -> &'static str {
43        self.code
44    }
45
46    #[must_use]
47    pub const fn client_message(&self) -> &'static str {
48        self.client_message
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct QueryError {
54    category: QueryErrorCategory,
55    code: &'static str,
56    client_message: &'static str,
57    sqlstate: Option<String>,
58    constraint: Option<String>,
59    message: String,
60    source: Option<Arc<sqlx::Error>>,
61}
62
63impl QueryError {
64    #[must_use]
65    pub fn from_classified(
66        category: QueryErrorCategory,
67        code: &'static str,
68        client_message: &'static str,
69        internal_message: impl Into<String>,
70    ) -> Self {
71        Self {
72            category,
73            code,
74            client_message,
75            sqlstate: None,
76            constraint: None,
77            message: internal_message.into(),
78            source: None,
79        }
80    }
81
82    #[must_use]
83    pub fn from_sqlx_with_constraint_classifier<F>(
84        error: sqlx::Error,
85        context: Option<&str>,
86        classify_constraint: F,
87    ) -> Self
88    where
89        F: Fn(&str) -> Option<FrameworkConstraintSpec>,
90    {
91        let (sqlstate, constraint, spec, raw_message) = if let Some(db) = error.as_database_error()
92        {
93            let sqlstate = db.code().map(|code| code.into_owned());
94            let constraint = db.constraint().map(ToOwned::to_owned);
95            let spec = classify_query_error_with_constraint_classifier(
96                &db.kind(),
97                sqlstate.as_deref(),
98                constraint.as_deref(),
99                classify_constraint,
100            );
101            (sqlstate, constraint, spec, db.message().to_owned())
102        } else {
103            (
104                None,
105                None,
106                QueryErrorSpec::internal().into(),
107                error.to_string(),
108            )
109        };
110
111        let message = match context {
112            Some(ctx) => format!("{ctx}: {raw_message}"),
113            None => raw_message,
114        };
115
116        Self {
117            category: spec.category(),
118            code: spec.code(),
119            client_message: spec.client_message(),
120            sqlstate,
121            constraint,
122            message,
123            source: Some(Arc::new(error)),
124        }
125    }
126
127    pub(crate) fn from_sqlx(error: sqlx::Error, context: Option<&str>) -> Self {
128        Self::from_sqlx_with_constraint_classifier(error, context, |_| None)
129    }
130
131    #[must_use]
132    pub const fn category(&self) -> QueryErrorCategory {
133        self.category
134    }
135
136    #[must_use]
137    pub const fn code(&self) -> &'static str {
138        self.code
139    }
140
141    #[must_use]
142    pub const fn client_message(&self) -> &'static str {
143        self.client_message
144    }
145
146    #[must_use]
147    pub fn sqlstate(&self) -> Option<&str> {
148        self.sqlstate.as_deref()
149    }
150
151    #[must_use]
152    pub fn constraint(&self) -> Option<&str> {
153        self.constraint.as_deref()
154    }
155
156    #[must_use]
157    pub fn internal_message(&self) -> &str {
158        &self.message
159    }
160
161    #[must_use]
162    pub fn source_arc(&self) -> Option<Arc<sqlx::Error>> {
163        self.source.clone()
164    }
165
166    #[must_use]
167    pub fn reclassified_with_constraint_classifier<F>(mut self, classify_constraint: F) -> Self
168    where
169        F: Fn(&str) -> Option<FrameworkConstraintSpec>,
170    {
171        let Some(spec) = self.constraint.as_deref().and_then(classify_constraint) else {
172            return self;
173        };
174
175        self.category = spec.category();
176        self.code = spec.code();
177        self.client_message = spec.client_message();
178        self
179    }
180}
181
182impl fmt::Display for QueryError {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        write!(f, "{}", self.message)
185    }
186}
187
188impl std::error::Error for QueryError {
189    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
190        self.source
191            .as_deref()
192            .map(|source| source as &(dyn std::error::Error + 'static))
193    }
194}
195
196#[derive(Debug, Clone, Copy)]
197struct QueryErrorSpec {
198    category: QueryErrorCategory,
199    code: &'static str,
200    client_message: &'static str,
201}
202
203impl QueryErrorSpec {
204    const fn conflict(code: &'static str, client_message: &'static str) -> Self {
205        Self {
206            category: QueryErrorCategory::Conflict,
207            code,
208            client_message,
209        }
210    }
211
212    const fn validation(code: &'static str, client_message: &'static str) -> Self {
213        Self {
214            category: QueryErrorCategory::Validation,
215            code,
216            client_message,
217        }
218    }
219
220    const fn forbidden(code: &'static str, client_message: &'static str) -> Self {
221        Self {
222            category: QueryErrorCategory::Forbidden,
223            code,
224            client_message,
225        }
226    }
227
228    const fn internal() -> Self {
229        Self {
230            category: QueryErrorCategory::Internal,
231            code: "db.query_failed",
232            client_message: "Database operation failed.",
233        }
234    }
235}
236
237impl From<QueryErrorSpec> for FrameworkConstraintSpec {
238    fn from(spec: QueryErrorSpec) -> Self {
239        Self::new(spec.category, spec.code, spec.client_message)
240    }
241}
242
243#[must_use]
244pub fn classify_query_error(
245    kind: &ErrorKind,
246    sqlstate: Option<&str>,
247    constraint: Option<&str>,
248) -> FrameworkConstraintSpec {
249    classify_query_error_with_constraint_classifier(kind, sqlstate, constraint, |_| None)
250}
251
252#[must_use]
253pub fn classify_query_error_with_constraint_classifier<F>(
254    kind: &ErrorKind,
255    sqlstate: Option<&str>,
256    constraint: Option<&str>,
257    classify_constraint: F,
258) -> FrameworkConstraintSpec
259where
260    F: Fn(&str) -> Option<FrameworkConstraintSpec>,
261{
262    if let Some(spec) = constraint.and_then(classify_constraint) {
263        return spec;
264    }
265
266    classify_database_error(kind, sqlstate, constraint).into()
267}
268
269fn classify_database_error(
270    kind: &ErrorKind,
271    sqlstate: Option<&str>,
272    constraint: Option<&str>,
273) -> QueryErrorSpec {
274    if let Some(spec) = constraint.and_then(classify_constraint) {
275        return spec;
276    }
277
278    match (kind, sqlstate) {
279        (ErrorKind::UniqueViolation, _) | (_, Some("23505")) => {
280            QueryErrorSpec::conflict("db.unique_violation", "Resource already exists.")
281        }
282        (ErrorKind::ForeignKeyViolation, _) | (_, Some("23503")) => QueryErrorSpec::validation(
283            "db.related_resource_missing",
284            "Related resource does not exist.",
285        ),
286        (_, Some("23001")) => QueryErrorSpec::validation(
287            "db.related_resource_still_referenced",
288            "Related resource is still referenced and cannot be deleted.",
289        ),
290        (ErrorKind::CheckViolation, _) | (_, Some("23514")) => QueryErrorSpec::validation(
291            "db.business_rule_violation",
292            "Request violates a business rule.",
293        ),
294        (ErrorKind::NotNullViolation, _) | (_, Some("23502")) => {
295            QueryErrorSpec::validation("db.required_field_missing", "Required data is missing.")
296        }
297        (_, Some("42501")) => {
298            QueryErrorSpec::forbidden("db.permission_denied", "Operation is not allowed.")
299        }
300        _ => QueryErrorSpec::internal(),
301    }
302}
303
304fn classify_constraint(constraint: &str) -> Option<QueryErrorSpec> {
305    classify::classify_constraint(constraint)
306}
307
308#[must_use]
309pub fn classify_framework_constraint(constraint: &str) -> Option<FrameworkConstraintSpec> {
310    classify_constraint(constraint).map(FrameworkConstraintSpec::from)
311}
312
313#[must_use]
314pub fn has_framework_constraint_classifier(constraint: &str) -> bool {
315    classify_framework_constraint(constraint).is_some()
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn classifies_job_idempotency_constraint() {
324        let spec = classify_database_error(
325            &ErrorKind::UniqueViolation,
326            Some("23505"),
327            Some("uq_job_queue_type_idempotency_org"),
328        );
329        assert_eq!(spec.category, QueryErrorCategory::Conflict);
330        assert_eq!(spec.code, "job.already_enqueued");
331    }
332
333    #[test]
334    fn classifies_global_job_idempotency_constraint() {
335        let spec = classify_database_error(
336            &ErrorKind::UniqueViolation,
337            Some("23505"),
338            Some("uq_job_queue_type_idempotency_global"),
339        );
340        assert_eq!(spec.category, QueryErrorCategory::Conflict);
341        assert_eq!(spec.code, "job.already_enqueued");
342    }
343
344    #[test]
345    fn classifies_workflow_idempotency_constraint() {
346        let spec = classify_database_error(
347            &ErrorKind::UniqueViolation,
348            Some("23505"),
349            Some("uq_workflow_runs_type_idempotency_org"),
350        );
351        assert_eq!(spec.category, QueryErrorCategory::Conflict);
352        assert_eq!(spec.code, "workflow.already_enqueued");
353    }
354
355    #[test]
356    fn classifies_global_workflow_idempotency_constraint() {
357        let spec = classify_database_error(
358            &ErrorKind::UniqueViolation,
359            Some("23505"),
360            Some("uq_workflow_runs_type_idempotency_global"),
361        );
362        assert_eq!(spec.category, QueryErrorCategory::Conflict);
363        assert_eq!(spec.code, "workflow.already_enqueued");
364    }
365
366    #[test]
367    fn classifies_job_definition_fk_constraint() {
368        let spec = classify_database_error(
369            &ErrorKind::ForeignKeyViolation,
370            Some("23503"),
371            Some("fk_job_queue_job_type"),
372        );
373        assert_eq!(spec.category, QueryErrorCategory::Validation);
374        assert_eq!(spec.code, "job.definition_not_found");
375    }
376
377    #[test]
378    fn classifies_job_runtime_config_definition_fk_constraint() {
379        let spec = classify_database_error(
380            &ErrorKind::ForeignKeyViolation,
381            Some("23503"),
382            Some("fk_job_runtime_configs_job_type"),
383        );
384        assert_eq!(spec.category, QueryErrorCategory::Validation);
385        assert_eq!(spec.code, "job.definition_not_found");
386    }
387
388    #[test]
389    fn classifies_job_organization_fk_constraint() {
390        let spec = classify_database_error(
391            &ErrorKind::ForeignKeyViolation,
392            Some("23503"),
393            Some("fk_job_queue_organization"),
394        );
395        assert_eq!(spec.category, QueryErrorCategory::Validation);
396        assert_eq!(spec.code, "job.organization_not_found");
397    }
398
399    #[test]
400    fn classifies_workflow_linkage_symmetry_constraint() {
401        let spec = classify_database_error(
402            &ErrorKind::CheckViolation,
403            Some("23514"),
404            Some("os_workflow_job_linkage_symmetry"),
405        );
406        assert_eq!(spec.category, QueryErrorCategory::Validation);
407        assert_eq!(spec.code, "workflow.linkage_symmetry_violation");
408    }
409
410    #[test]
411    fn classifies_workflow_linkage_symmetry_trigger_table_constraint() {
412        let spec = classify_database_error(
413            &ErrorKind::CheckViolation,
414            Some("23514"),
415            Some("os_workflow_job_linkage_symmetry_trigger_table"),
416        );
417        assert_eq!(spec.category, QueryErrorCategory::Validation);
418        assert_eq!(spec.code, "workflow.linkage_symmetry_trigger_table_invalid");
419    }
420
421    #[test]
422    fn classifies_external_gate_downgrade_blocked_constraint() {
423        let spec = classify_database_error(
424            &ErrorKind::CheckViolation,
425            Some("23514"),
426            Some("os_workflow_external_gate_downgrade_waiting_runs_exist"),
427        );
428        assert_eq!(spec.category, QueryErrorCategory::Validation);
429        assert_eq!(spec.code, "workflow.external_gate_downgrade_blocked");
430    }
431
432    #[test]
433    fn custom_constraint_classifier_takes_precedence() {
434        let spec = classify_query_error_with_constraint_classifier(
435            &ErrorKind::UniqueViolation,
436            Some("23505"),
437            Some("os_custom_override"),
438            |constraint| {
439                (constraint == "os_custom_override").then_some(FrameworkConstraintSpec::new(
440                    QueryErrorCategory::Forbidden,
441                    "custom.override",
442                    "Custom override wins.",
443                ))
444            },
445        );
446        assert_eq!(spec.category(), QueryErrorCategory::Forbidden);
447        assert_eq!(spec.code(), "custom.override");
448        assert_eq!(spec.client_message(), "Custom override wins.");
449    }
450
451    #[test]
452    fn classifies_permission_denied() {
453        let spec = classify_database_error(&ErrorKind::Other, Some("42501"), None);
454        assert_eq!(spec.category, QueryErrorCategory::Forbidden);
455        assert_eq!(spec.code, "db.permission_denied");
456    }
457
458    #[test]
459    fn falls_back_to_internal_for_unmapped_errors() {
460        let spec = classify_database_error(&ErrorKind::Other, Some("99999"), Some("not_mapped"));
461        assert_eq!(spec.category, QueryErrorCategory::Internal);
462        assert_eq!(spec.code, "db.query_failed");
463    }
464}