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)]
22pub enum QueryErrorKind {
23 JobLeaseOwnerMismatch,
24 JobInvalidCompletionProgress,
25 JobInvalidContinuationDelay,
26 JobInvalidRetryTiming,
27 JobUnstartedClaimReleaseNotApplicable,
28 JobWorkflowHandlerContinuationNotEnabled,
29 JobWorkflowRequeueNotSupported,
30 PostgresLockNotAvailable,
31 WorkflowReleaseConflict,
32}
33
34impl QueryErrorKind {
35 const fn spec(self) -> QueryErrorSpec {
36 match self {
37 Self::JobLeaseOwnerMismatch => QueryErrorSpec::forbidden(
38 "job.lease_owner_mismatch",
39 "Job lease is not currently held by this worker.",
40 ),
41 Self::JobInvalidCompletionProgress => QueryErrorSpec::validation(
42 "job.invalid_completion_progress",
43 "Job completion progress is invalid.",
44 ),
45 Self::JobInvalidContinuationDelay => QueryErrorSpec::validation(
46 "job.invalid_continuation_delay",
47 "Job continuation delay is too large.",
48 ),
49 Self::JobInvalidRetryTiming => QueryErrorSpec::validation(
50 "job.invalid_retry_timing",
51 "Job retry timing is invalid.",
52 ),
53 Self::JobUnstartedClaimReleaseNotApplicable => QueryErrorSpec::validation(
54 "job.unstarted_claim_release_not_applicable",
55 "Job claim cannot be released as unstarted.",
56 ),
57 Self::JobWorkflowHandlerContinuationNotEnabled => QueryErrorSpec::validation(
58 "job.workflow_handler_continuation_not_enabled",
59 "Workflow step handler continuation is not enabled.",
60 ),
61 Self::JobWorkflowRequeueNotSupported => QueryErrorSpec::validation(
62 "job.workflow_requeue_not_supported",
63 "Workflow-managed jobs cannot be requeued directly.",
64 ),
65 Self::PostgresLockNotAvailable => QueryErrorSpec::internal(),
66 Self::WorkflowReleaseConflict => QueryErrorSpec::conflict(
67 "workflow.release_conflict",
68 "Workflow step release conflicted with another workflow mutation.",
69 ),
70 }
71 }
72
73 fn from_sqlstate(sqlstate: Option<&str>) -> Option<Self> {
74 match sqlstate {
75 Some("55P03") => Some(Self::PostgresLockNotAvailable),
76 _ => None,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub(crate) struct SanitizedQueryErrorDiagnostics<'a> {
89 code: &'a str,
90 sqlstate: Option<&'a str>,
91 constraint: Option<&'a str>,
92}
93
94impl<'a> SanitizedQueryErrorDiagnostics<'a> {
95 #[must_use]
96 pub(crate) const fn from_code(code: &'a str) -> Self {
97 Self {
98 code,
99 sqlstate: None,
100 constraint: None,
101 }
102 }
103
104 #[must_use]
105 pub(crate) const fn code(self) -> &'a str {
106 self.code
107 }
108
109 #[must_use]
110 pub(crate) const fn sqlstate(self) -> Option<&'a str> {
111 self.sqlstate
112 }
113
114 #[must_use]
115 pub(crate) const fn constraint(self) -> Option<&'a str> {
116 self.constraint
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct FrameworkConstraintSpec {
122 category: QueryErrorCategory,
123 code: &'static str,
124 client_message: &'static str,
125}
126
127impl FrameworkConstraintSpec {
128 #[must_use]
129 pub const fn new(
130 category: QueryErrorCategory,
131 code: &'static str,
132 client_message: &'static str,
133 ) -> Self {
134 Self {
135 category,
136 code,
137 client_message,
138 }
139 }
140
141 #[must_use]
142 pub const fn category(&self) -> QueryErrorCategory {
143 self.category
144 }
145
146 #[must_use]
147 pub const fn code(&self) -> &'static str {
148 self.code
149 }
150
151 #[must_use]
152 pub const fn client_message(&self) -> &'static str {
153 self.client_message
154 }
155}
156
157#[derive(Clone)]
158enum QueryErrorClassification {
159 Fixed(QueryErrorKind),
160 Classified(QueryErrorSpec),
161}
162
163impl QueryErrorClassification {
164 const fn spec(&self) -> QueryErrorSpec {
165 match self {
166 Self::Fixed(kind) => kind.spec(),
167 Self::Classified(spec) => *spec,
168 }
169 }
170
171 const fn kind(&self) -> Option<QueryErrorKind> {
172 match self {
173 Self::Fixed(kind) => Some(*kind),
174 Self::Classified(_) => None,
175 }
176 }
177}
178
179#[derive(Clone)]
180pub struct QueryError {
181 classification: QueryErrorClassification,
182 sqlstate: Option<String>,
183 constraint: Option<String>,
184 message: String,
185 source: Option<Arc<sqlx::Error>>,
186}
187
188impl QueryError {
189 #[must_use]
190 pub fn from_classified(
191 category: QueryErrorCategory,
192 code: &'static str,
193 client_message: &'static str,
194 internal_message: impl Into<String>,
195 ) -> Self {
196 Self {
197 classification: QueryErrorClassification::Classified(QueryErrorSpec {
198 category,
199 code,
200 client_message,
201 }),
202 sqlstate: None,
203 constraint: None,
204 message: internal_message.into(),
205 source: None,
206 }
207 }
208
209 #[must_use]
210 pub(crate) fn from_kind(kind: QueryErrorKind, internal_message: impl Into<String>) -> Self {
211 Self {
212 classification: QueryErrorClassification::Fixed(kind),
213 sqlstate: None,
214 constraint: None,
215 message: internal_message.into(),
216 source: None,
217 }
218 }
219
220 #[must_use]
221 pub(crate) fn from_sqlx_with_kind(
222 kind: QueryErrorKind,
223 internal_message: impl Into<String>,
224 source: sqlx::Error,
225 ) -> Self {
226 let (sqlstate, constraint) = source
227 .as_database_error()
228 .map(|database_error| {
229 (
230 database_error.code().map(|code| code.into_owned()),
231 database_error.constraint().map(ToOwned::to_owned),
232 )
233 })
234 .unwrap_or((None, None));
235
236 Self {
237 classification: QueryErrorClassification::Fixed(kind),
238 sqlstate,
239 constraint,
240 message: internal_message.into(),
241 source: Some(Arc::new(source)),
242 }
243 }
244
245 #[must_use]
246 pub fn from_sqlx_with_constraint_classifier<F>(
247 error: sqlx::Error,
248 context: Option<&str>,
249 classify_constraint: F,
250 ) -> Self
251 where
252 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
253 {
254 let (sqlstate, constraint, spec, raw_message) = if let Some(db) = error.as_database_error()
255 {
256 let sqlstate = db.code().map(|code| code.into_owned());
257 let constraint = db.constraint().map(ToOwned::to_owned);
258 let spec = classify_query_error_with_constraint_classifier(
259 &db.kind(),
260 sqlstate.as_deref(),
261 constraint.as_deref(),
262 classify_constraint,
263 );
264 (sqlstate, constraint, spec, db.message().to_owned())
265 } else {
266 (
267 None,
268 None,
269 QueryErrorSpec::internal().into(),
270 error.to_string(),
271 )
272 };
273
274 let message = match context {
275 Some(ctx) => format!("{ctx}: {raw_message}"),
276 None => raw_message,
277 };
278
279 let classification = match QueryErrorKind::from_sqlstate(sqlstate.as_deref()) {
280 Some(kind) => QueryErrorClassification::Fixed(kind),
281 None => QueryErrorClassification::Classified(QueryErrorSpec {
282 category: spec.category(),
283 code: spec.code(),
284 client_message: spec.client_message(),
285 }),
286 };
287
288 Self {
289 classification,
290 sqlstate,
291 constraint,
292 message,
293 source: Some(Arc::new(error)),
294 }
295 }
296
297 pub(crate) fn from_sqlx(error: sqlx::Error, context: Option<&str>) -> Self {
298 Self::from_sqlx_with_constraint_classifier(error, context, |_| None)
299 }
300
301 #[must_use]
302 pub const fn category(&self) -> QueryErrorCategory {
303 self.classification.spec().category
304 }
305
306 #[must_use]
309 pub const fn kind(&self) -> Option<QueryErrorKind> {
310 self.classification.kind()
311 }
312
313 #[must_use]
314 pub const fn code(&self) -> &'static str {
315 self.classification.spec().code
316 }
317
318 #[must_use]
319 pub const fn client_message(&self) -> &'static str {
320 self.classification.spec().client_message
321 }
322
323 #[must_use]
324 pub fn sqlstate(&self) -> Option<&str> {
325 self.sqlstate.as_deref()
326 }
327
328 #[must_use]
329 pub fn constraint(&self) -> Option<&str> {
330 self.constraint.as_deref()
331 }
332
333 #[must_use]
334 pub(crate) fn sanitized_diagnostics(&self) -> SanitizedQueryErrorDiagnostics<'_> {
335 SanitizedQueryErrorDiagnostics {
336 code: self.code(),
337 sqlstate: self.sqlstate(),
338 constraint: self.constraint(),
339 }
340 }
341
342 #[must_use]
343 pub fn internal_message(&self) -> &str {
344 &self.message
345 }
346
347 #[must_use]
354 pub fn source_arc(&self) -> Option<Arc<sqlx::Error>> {
355 self.source.clone()
356 }
357
358 #[must_use]
359 pub fn reclassified_with_constraint_classifier<F>(mut self, classify_constraint: F) -> Self
360 where
361 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
362 {
363 let Some(spec) = self.constraint.as_deref().and_then(classify_constraint) else {
364 return self;
365 };
366
367 self.classification = QueryErrorClassification::Classified(QueryErrorSpec {
368 category: spec.category(),
369 code: spec.code(),
370 client_message: spec.client_message(),
371 });
372 self
373 }
374}
375
376impl fmt::Debug for QueryError {
377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 f.debug_struct("QueryError")
379 .field("category", &self.category())
380 .field("kind", &self.kind())
381 .field("code", &self.code())
382 .field("client_message", &self.client_message())
383 .field("sqlstate", &self.sqlstate)
384 .field("constraint", &self.constraint)
385 .field("has_source", &self.source.is_some())
386 .finish()
387 }
388}
389
390impl fmt::Display for QueryError {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 write!(f, "{}", self.client_message())
393 }
394}
395
396impl std::error::Error for QueryError {
397 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
398 self.source
399 .as_deref()
400 .map(|source| source as &(dyn std::error::Error + 'static))
401 }
402}
403
404#[derive(Debug, Clone, Copy)]
405struct QueryErrorSpec {
406 category: QueryErrorCategory,
407 code: &'static str,
408 client_message: &'static str,
409}
410
411impl QueryErrorSpec {
412 const fn conflict(code: &'static str, client_message: &'static str) -> Self {
413 Self {
414 category: QueryErrorCategory::Conflict,
415 code,
416 client_message,
417 }
418 }
419
420 const fn validation(code: &'static str, client_message: &'static str) -> Self {
421 Self {
422 category: QueryErrorCategory::Validation,
423 code,
424 client_message,
425 }
426 }
427
428 const fn forbidden(code: &'static str, client_message: &'static str) -> Self {
429 Self {
430 category: QueryErrorCategory::Forbidden,
431 code,
432 client_message,
433 }
434 }
435
436 const fn internal() -> Self {
437 Self {
438 category: QueryErrorCategory::Internal,
439 code: "db.query_failed",
440 client_message: "Database operation failed.",
441 }
442 }
443}
444
445impl From<QueryErrorSpec> for FrameworkConstraintSpec {
446 fn from(spec: QueryErrorSpec) -> Self {
447 Self::new(spec.category, spec.code, spec.client_message)
448 }
449}
450
451#[must_use]
452pub fn classify_query_error(
453 kind: &ErrorKind,
454 sqlstate: Option<&str>,
455 constraint: Option<&str>,
456) -> FrameworkConstraintSpec {
457 classify_query_error_with_constraint_classifier(kind, sqlstate, constraint, |_| None)
458}
459
460#[must_use]
461pub fn classify_query_error_with_constraint_classifier<F>(
462 kind: &ErrorKind,
463 sqlstate: Option<&str>,
464 constraint: Option<&str>,
465 classify_constraint: F,
466) -> FrameworkConstraintSpec
467where
468 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
469{
470 if let Some(spec) = constraint.and_then(classify_constraint) {
471 return spec;
472 }
473
474 classify_database_error(kind, sqlstate, constraint).into()
475}
476
477fn classify_database_error(
478 kind: &ErrorKind,
479 sqlstate: Option<&str>,
480 constraint: Option<&str>,
481) -> QueryErrorSpec {
482 if let Some(spec) = constraint.and_then(classify_constraint) {
483 return spec;
484 }
485
486 match (kind, sqlstate) {
487 (ErrorKind::UniqueViolation, _) | (_, Some("23505")) => {
488 QueryErrorSpec::conflict("db.unique_violation", "Resource already exists.")
489 }
490 (ErrorKind::ForeignKeyViolation, _) | (_, Some("23503")) => QueryErrorSpec::validation(
491 "db.related_resource_missing",
492 "Related resource does not exist.",
493 ),
494 (_, Some("23001")) => QueryErrorSpec::validation(
495 "db.related_resource_still_referenced",
496 "Related resource is still referenced and cannot be deleted.",
497 ),
498 (ErrorKind::CheckViolation, _) | (_, Some("23514")) => QueryErrorSpec::validation(
499 "db.business_rule_violation",
500 "Request violates a business rule.",
501 ),
502 (ErrorKind::NotNullViolation, _) | (_, Some("23502")) => {
503 QueryErrorSpec::validation("db.required_field_missing", "Required data is missing.")
504 }
505 (_, Some("42501")) => {
506 QueryErrorSpec::forbidden("db.permission_denied", "Operation is not allowed.")
507 }
508 _ => QueryErrorSpec::internal(),
509 }
510}
511
512fn classify_constraint(constraint: &str) -> Option<QueryErrorSpec> {
513 classify::classify_constraint(constraint)
514}
515
516#[must_use]
517pub fn classify_framework_constraint(constraint: &str) -> Option<FrameworkConstraintSpec> {
518 classify_constraint(constraint).map(FrameworkConstraintSpec::from)
519}
520
521#[must_use]
522pub fn has_framework_constraint_classifier(constraint: &str) -> bool {
523 classify_framework_constraint(constraint).is_some()
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn classifies_job_idempotency_constraint() {
532 let spec = classify_database_error(
533 &ErrorKind::UniqueViolation,
534 Some("23505"),
535 Some("uq_job_queue_type_idempotency_org"),
536 );
537 assert_eq!(spec.category, QueryErrorCategory::Conflict);
538 assert_eq!(spec.code, "job.already_enqueued");
539 }
540
541 #[test]
542 fn classifies_global_job_idempotency_constraint() {
543 let spec = classify_database_error(
544 &ErrorKind::UniqueViolation,
545 Some("23505"),
546 Some("uq_job_queue_type_idempotency_global"),
547 );
548 assert_eq!(spec.category, QueryErrorCategory::Conflict);
549 assert_eq!(spec.code, "job.already_enqueued");
550 }
551
552 #[test]
553 fn classifies_workflow_idempotency_constraint() {
554 let spec = classify_database_error(
555 &ErrorKind::UniqueViolation,
556 Some("23505"),
557 Some("uq_workflow_runs_type_idempotency_org"),
558 );
559 assert_eq!(spec.category, QueryErrorCategory::Conflict);
560 assert_eq!(spec.code, "workflow.already_enqueued");
561 }
562
563 #[test]
564 fn classifies_global_workflow_idempotency_constraint() {
565 let spec = classify_database_error(
566 &ErrorKind::UniqueViolation,
567 Some("23505"),
568 Some("uq_workflow_runs_type_idempotency_global"),
569 );
570 assert_eq!(spec.category, QueryErrorCategory::Conflict);
571 assert_eq!(spec.code, "workflow.already_enqueued");
572 }
573
574 #[test]
575 fn classifies_job_definition_fk_constraint() {
576 let spec = classify_database_error(
577 &ErrorKind::ForeignKeyViolation,
578 Some("23503"),
579 Some("fk_job_queue_job_type"),
580 );
581 assert_eq!(spec.category, QueryErrorCategory::Validation);
582 assert_eq!(spec.code, "job.definition_not_found");
583 }
584
585 #[test]
586 fn classifies_job_runtime_config_definition_fk_constraint() {
587 let spec = classify_database_error(
588 &ErrorKind::ForeignKeyViolation,
589 Some("23503"),
590 Some("fk_job_runtime_configs_job_type"),
591 );
592 assert_eq!(spec.category, QueryErrorCategory::Validation);
593 assert_eq!(spec.code, "job.definition_not_found");
594 }
595
596 #[test]
597 fn classifies_job_organization_fk_constraint() {
598 let spec = classify_database_error(
599 &ErrorKind::ForeignKeyViolation,
600 Some("23503"),
601 Some("fk_job_queue_organization"),
602 );
603 assert_eq!(spec.category, QueryErrorCategory::Validation);
604 assert_eq!(spec.code, "job.organization_not_found");
605 }
606
607 #[test]
608 fn classifies_workflow_linkage_symmetry_constraint() {
609 let spec = classify_database_error(
610 &ErrorKind::CheckViolation,
611 Some("23514"),
612 Some("os_workflow_job_linkage_symmetry"),
613 );
614 assert_eq!(spec.category, QueryErrorCategory::Validation);
615 assert_eq!(spec.code, "workflow.linkage_symmetry_violation");
616 }
617
618 #[test]
619 fn classifies_workflow_linkage_symmetry_trigger_table_constraint() {
620 let spec = classify_database_error(
621 &ErrorKind::CheckViolation,
622 Some("23514"),
623 Some("os_workflow_job_linkage_symmetry_trigger_table"),
624 );
625 assert_eq!(spec.category, QueryErrorCategory::Validation);
626 assert_eq!(spec.code, "workflow.linkage_symmetry_trigger_table_invalid");
627 }
628
629 #[test]
630 fn classifies_workflow_linkage_cutover_audit_constraints() {
631 for constraint in [
632 "os_workflow_job_linkage_expand_audit",
633 "os_workflow_job_linkage_expand_rollback_audit",
634 "os_workflow_job_linkage_contract_audit",
635 ] {
636 let spec = classify_database_error(
637 &ErrorKind::CheckViolation,
638 Some("23514"),
639 Some(constraint),
640 );
641 assert_eq!(spec.code, "workflow.linkage_cutover_audit_failed");
642 }
643 }
644
645 #[test]
646 fn classifies_workflow_linkage_compatibility_trigger_table_constraint() {
647 let spec = classify_database_error(
648 &ErrorKind::CheckViolation,
649 Some("23514"),
650 Some("os_workflow_job_linkage_compatibility_trigger_table"),
651 );
652 assert_eq!(
653 spec.code,
654 "workflow.linkage_compatibility_trigger_table_invalid"
655 );
656 }
657
658 #[test]
659 fn classifies_external_gate_downgrade_blocked_constraint() {
660 let spec = classify_database_error(
661 &ErrorKind::CheckViolation,
662 Some("23514"),
663 Some("os_workflow_external_gate_downgrade_waiting_runs_exist"),
664 );
665 assert_eq!(spec.category, QueryErrorCategory::Validation);
666 assert_eq!(spec.code, "workflow.external_gate_downgrade_blocked");
667 }
668
669 #[test]
670 fn custom_constraint_classifier_takes_precedence() {
671 let spec = classify_query_error_with_constraint_classifier(
672 &ErrorKind::UniqueViolation,
673 Some("23505"),
674 Some("os_custom_override"),
675 |constraint| {
676 (constraint == "os_custom_override").then_some(FrameworkConstraintSpec::new(
677 QueryErrorCategory::Forbidden,
678 "custom.override",
679 "Custom override wins.",
680 ))
681 },
682 );
683 assert_eq!(spec.category(), QueryErrorCategory::Forbidden);
684 assert_eq!(spec.code(), "custom.override");
685 assert_eq!(spec.client_message(), "Custom override wins.");
686 }
687
688 #[test]
689 fn query_error_debug_omits_internal_message() {
690 let error = QueryError::from_classified(
691 QueryErrorCategory::Conflict,
692 "job.idempotency_conflict",
693 "Job enqueue retry conflicts with the existing idempotency key.",
694 "internal context includes secret-idempotency-key",
695 );
696
697 let debug = format!("{error:?}");
698 assert_eq!(
699 debug,
700 "QueryError { category: Conflict, kind: None, code: \"job.idempotency_conflict\", client_message: \"Job enqueue retry conflicts with the existing idempotency key.\", sqlstate: None, constraint: None, has_source: false }"
701 );
702 assert!(!debug.contains("secret-idempotency-key"));
703
704 let display = error.to_string();
705 assert_eq!(
706 display,
707 "Job enqueue retry conflicts with the existing idempotency key."
708 );
709 assert!(!display.contains("secret-idempotency-key"));
710 }
711
712 #[test]
713 fn query_error_from_sqlx_uses_sanitized_display_and_debug() {
714 let error = QueryError::from_sqlx(
715 sqlx::Error::Protocol("internal secret-idempotency-key detail".into()),
716 Some("sensitive context"),
717 );
718
719 let display = error.to_string();
720 assert_eq!(display, "Database operation failed.");
721 assert!(!display.contains("secret-idempotency-key"));
722
723 let debug = format!("{error:?}");
724 assert!(debug.contains("db.query_failed"));
725 assert!(!debug.contains("secret-idempotency-key"));
726 assert!(error.internal_message().contains("secret-idempotency-key"));
727 assert!(std::error::Error::source(&error).is_some());
728 assert!(error.source_arc().is_some());
729 }
730
731 #[test]
732 fn sanitized_diagnostics_omit_internal_message_and_source() {
733 let error = QueryError::from_sqlx(
734 sqlx::Error::Protocol("database detail includes secret-idempotency-key".into()),
735 Some("sensitive context includes secret-idempotency-key"),
736 );
737
738 let diagnostics = error.sanitized_diagnostics();
739
740 assert_eq!(diagnostics.code(), "db.query_failed");
741 assert_eq!(diagnostics.sqlstate(), None);
742 assert_eq!(diagnostics.constraint(), None);
743 let debug = format!("{diagnostics:?}");
744 assert!(debug.contains("db.query_failed"));
745 assert!(!debug.contains("secret-idempotency-key"));
746 }
747
748 #[test]
749 fn typed_query_error_from_classified_sqlx_preserves_source_without_leaking_display() {
750 let error = QueryError::from_sqlx_with_kind(
751 QueryErrorKind::WorkflowReleaseConflict,
752 "internal context includes secret-lock-key",
753 sqlx::Error::Protocol("database detail includes secret-lock-key".into()),
754 );
755
756 assert_eq!(error.category(), QueryErrorCategory::Conflict);
757 assert_eq!(error.kind(), Some(QueryErrorKind::WorkflowReleaseConflict));
758 assert_eq!(error.code(), "workflow.release_conflict");
759 assert_eq!(
760 error.client_message(),
761 "Workflow step release conflicted with another workflow mutation."
762 );
763 assert!(error.internal_message().contains("secret-lock-key"));
764 assert!(error.source_arc().is_some());
765 assert!(std::error::Error::source(&error).is_some());
766
767 let display = error.to_string();
768 assert_eq!(
769 display,
770 "Workflow step release conflicted with another workflow mutation."
771 );
772 assert!(!display.contains("secret-lock-key"));
773
774 let debug = format!("{error:?}");
775 assert_eq!(
776 debug,
777 "QueryError { category: Conflict, kind: Some(WorkflowReleaseConflict), code: \"workflow.release_conflict\", client_message: \"Workflow step release conflicted with another workflow mutation.\", sqlstate: None, constraint: None, has_source: true }"
778 );
779 assert!(!debug.contains("secret-lock-key"));
780 }
781
782 #[test]
783 fn fixed_query_error_kind_metadata_is_exhaustive_and_stable() {
784 let cases = [
785 (
786 QueryErrorKind::JobLeaseOwnerMismatch,
787 QueryErrorCategory::Forbidden,
788 "job.lease_owner_mismatch",
789 "Job lease is not currently held by this worker.",
790 ),
791 (
792 QueryErrorKind::JobInvalidCompletionProgress,
793 QueryErrorCategory::Validation,
794 "job.invalid_completion_progress",
795 "Job completion progress is invalid.",
796 ),
797 (
798 QueryErrorKind::JobInvalidContinuationDelay,
799 QueryErrorCategory::Validation,
800 "job.invalid_continuation_delay",
801 "Job continuation delay is too large.",
802 ),
803 (
804 QueryErrorKind::JobInvalidRetryTiming,
805 QueryErrorCategory::Validation,
806 "job.invalid_retry_timing",
807 "Job retry timing is invalid.",
808 ),
809 (
810 QueryErrorKind::JobUnstartedClaimReleaseNotApplicable,
811 QueryErrorCategory::Validation,
812 "job.unstarted_claim_release_not_applicable",
813 "Job claim cannot be released as unstarted.",
814 ),
815 (
816 QueryErrorKind::JobWorkflowHandlerContinuationNotEnabled,
817 QueryErrorCategory::Validation,
818 "job.workflow_handler_continuation_not_enabled",
819 "Workflow step handler continuation is not enabled.",
820 ),
821 (
822 QueryErrorKind::JobWorkflowRequeueNotSupported,
823 QueryErrorCategory::Validation,
824 "job.workflow_requeue_not_supported",
825 "Workflow-managed jobs cannot be requeued directly.",
826 ),
827 (
828 QueryErrorKind::PostgresLockNotAvailable,
829 QueryErrorCategory::Internal,
830 "db.query_failed",
831 "Database operation failed.",
832 ),
833 (
834 QueryErrorKind::WorkflowReleaseConflict,
835 QueryErrorCategory::Conflict,
836 "workflow.release_conflict",
837 "Workflow step release conflicted with another workflow mutation.",
838 ),
839 ];
840
841 for (kind, expected_category, expected_code, expected_client_message) in cases {
842 let error = QueryError::from_kind(kind, "internal detail");
843
844 assert_eq!(error.kind(), Some(kind));
845 assert_eq!(error.category(), expected_category);
846 assert_eq!(error.code(), expected_code);
847 assert_eq!(error.client_message(), expected_client_message);
848 }
849 }
850
851 #[test]
852 fn maps_only_postgres_lock_not_available_to_a_runtime_policy_kind() {
853 assert_eq!(
854 QueryErrorKind::from_sqlstate(Some("55P03")),
855 Some(QueryErrorKind::PostgresLockNotAvailable)
856 );
857 assert_eq!(QueryErrorKind::from_sqlstate(Some("57014")), None);
858 assert_eq!(QueryErrorKind::from_sqlstate(None), None);
859 }
860
861 #[test]
862 fn classifies_permission_denied() {
863 let spec = classify_database_error(&ErrorKind::Other, Some("42501"), None);
864 assert_eq!(spec.category, QueryErrorCategory::Forbidden);
865 assert_eq!(spec.code, "db.permission_denied");
866 }
867
868 #[test]
869 fn falls_back_to_internal_for_unmapped_errors() {
870 let spec = classify_database_error(&ErrorKind::Other, Some("99999"), Some("not_mapped"));
871 assert_eq!(spec.category, QueryErrorCategory::Internal);
872 assert_eq!(spec.code, "db.query_failed");
873 }
874}