Skip to main content

helios_persistence/
error.rs

1//! Error types for the persistence layer.
2//!
3//! This module defines all error types used throughout the persistence layer,
4//! following a hierarchy that separates storage errors, tenant errors, search errors,
5//! and transaction errors.
6
7use std::fmt;
8
9use thiserror::Error;
10
11use crate::tenant::TenantId;
12
13/// The primary error type for all storage operations.
14///
15/// This enum encompasses all possible errors that can occur during persistence
16/// operations, organized by category.
17#[derive(Error, Debug)]
18pub enum StorageError {
19    /// Resource state errors
20    #[error(transparent)]
21    Resource(#[from] ResourceError),
22
23    /// Concurrency and versioning errors
24    #[error(transparent)]
25    Concurrency(#[from] ConcurrencyError),
26
27    /// Tenant isolation errors
28    #[error(transparent)]
29    Tenant(#[from] TenantError),
30
31    /// Validation errors
32    #[error(transparent)]
33    Validation(#[from] ValidationError),
34
35    /// Search operation errors
36    #[error(transparent)]
37    Search(#[from] SearchError),
38
39    /// Transaction errors
40    #[error(transparent)]
41    Transaction(#[from] TransactionError),
42
43    /// Backend-specific errors
44    #[error(transparent)]
45    Backend(#[from] BackendError),
46
47    /// Bulk export errors
48    #[error(transparent)]
49    BulkExport(#[from] BulkExportError),
50
51    /// Bulk submit errors
52    #[error(transparent)]
53    BulkSubmit(#[from] BulkSubmitError),
54}
55
56/// Errors related to resource state.
57#[derive(Error, Debug)]
58pub enum ResourceError {
59    /// The requested resource was not found.
60    #[error("resource not found: {resource_type}/{id}")]
61    NotFound {
62        /// FHIR resource type (e.g., `Patient`).
63        resource_type: String,
64        /// Logical id of the missing resource.
65        id: String,
66    },
67
68    /// A resource with the given ID already exists.
69    #[error("resource already exists: {resource_type}/{id}")]
70    AlreadyExists {
71        /// FHIR resource type.
72        resource_type: String,
73        /// Logical id that is already in use.
74        id: String,
75    },
76
77    /// The resource has been deleted (HTTP 410 Gone).
78    #[error("resource deleted: {resource_type}/{id}")]
79    Gone {
80        /// FHIR resource type of the deleted resource.
81        resource_type: String,
82        /// Logical id of the deleted resource.
83        id: String,
84        /// Timestamp at which the resource was deleted, when known.
85        deleted_at: Option<chrono::DateTime<chrono::Utc>>,
86    },
87
88    /// The requested version of the resource was not found.
89    #[error("version not found: {resource_type}/{id}/_history/{version_id}")]
90    VersionNotFound {
91        /// FHIR resource type.
92        resource_type: String,
93        /// Logical id of the resource.
94        id: String,
95        /// Version id that could not be located.
96        version_id: String,
97    },
98}
99
100/// Errors related to concurrency control.
101#[derive(Error, Debug)]
102pub enum ConcurrencyError {
103    /// Version conflict detected during optimistic locking.
104    #[error("version conflict: expected {expected_version}, found {actual_version}")]
105    VersionConflict {
106        /// FHIR resource type.
107        resource_type: String,
108        /// Logical id of the resource.
109        id: String,
110        /// Version id the client expected.
111        expected_version: String,
112        /// Version id currently stored.
113        actual_version: String,
114    },
115
116    /// Optimistic lock failure (If-Match precondition failed).
117    #[error("optimistic lock failure: resource {resource_type}/{id} has been modified")]
118    OptimisticLockFailure {
119        /// FHIR resource type.
120        resource_type: String,
121        /// Logical id of the resource.
122        id: String,
123        /// ETag value supplied by the client.
124        expected_etag: String,
125        /// Current ETag, if it could be read.
126        actual_etag: Option<String>,
127    },
128
129    /// Deadlock detected during pessimistic locking.
130    #[error("deadlock detected while accessing {resource_type}/{id}")]
131    Deadlock {
132        /// FHIR resource type.
133        resource_type: String,
134        /// Logical id of the resource.
135        id: String,
136    },
137
138    /// Lock acquisition timed out.
139    #[error("lock timeout after {timeout_ms}ms for {resource_type}/{id}")]
140    LockTimeout {
141        /// FHIR resource type.
142        resource_type: String,
143        /// Logical id of the resource.
144        id: String,
145        /// Lock-acquisition timeout that elapsed.
146        timeout_ms: u64,
147    },
148}
149
150/// Errors related to tenant isolation.
151#[derive(Error, Debug)]
152pub enum TenantError {
153    /// Access to resource denied for the current tenant.
154    #[error("access denied: tenant {tenant_id} cannot access {resource_type}/{resource_id}")]
155    AccessDenied {
156        /// Tenant attempting the access.
157        tenant_id: TenantId,
158        /// FHIR resource type.
159        resource_type: String,
160        /// Logical id of the protected resource.
161        resource_id: String,
162    },
163
164    /// The specified tenant does not exist or is invalid.
165    #[error("invalid tenant: {tenant_id}")]
166    InvalidTenant {
167        /// Tenant identifier that failed validation.
168        tenant_id: TenantId,
169    },
170
171    /// Tenant is suspended and cannot perform operations.
172    #[error("tenant suspended: {tenant_id}")]
173    TenantSuspended {
174        /// Identifier of the suspended tenant.
175        tenant_id: TenantId,
176    },
177
178    /// Cross-tenant reference not allowed.
179    #[error(
180        "cross-tenant reference not allowed: resource in tenant {source_tenant} references resource in tenant {target_tenant}"
181    )]
182    CrossTenantReference {
183        /// Tenant owning the referring resource.
184        source_tenant: TenantId,
185        /// Tenant owning the referenced resource.
186        target_tenant: TenantId,
187        /// Reference value that crossed the boundary.
188        reference: String,
189    },
190
191    /// Operation not permitted for tenant.
192    #[error("operation {operation} not permitted for tenant {tenant_id}")]
193    OperationNotPermitted {
194        /// Tenant attempting the operation.
195        tenant_id: TenantId,
196        /// Name of the operation that was rejected.
197        operation: String,
198    },
199}
200
201/// Errors related to resource validation.
202#[derive(Error, Debug)]
203pub enum ValidationError {
204    /// The resource failed validation.
205    #[error("invalid resource: {message}")]
206    InvalidResource {
207        /// Human-readable summary of the failure.
208        message: String,
209        /// Per-field validation details.
210        details: Vec<ValidationDetail>,
211    },
212
213    /// The search parameter is invalid.
214    #[error("invalid search parameter: {parameter}")]
215    InvalidSearchParameter {
216        /// Name of the offending search parameter.
217        parameter: String,
218        /// Human-readable explanation of the failure.
219        message: String,
220    },
221
222    /// The resource type is not supported.
223    #[error("unsupported resource type: {resource_type}")]
224    UnsupportedResourceType {
225        /// Unsupported FHIR resource type.
226        resource_type: String,
227    },
228
229    /// Missing required field.
230    #[error("missing required field: {field}")]
231    MissingRequiredField {
232        /// Name of the missing field.
233        field: String,
234    },
235
236    /// Invalid reference format.
237    #[error("invalid reference: {reference}")]
238    InvalidReference {
239        /// Reference string that failed parsing.
240        reference: String,
241        /// Human-readable failure detail.
242        message: String,
243    },
244}
245
246/// Detailed validation error information.
247#[derive(Debug, Clone)]
248pub struct ValidationDetail {
249    /// The path to the field with the error (FHIRPath expression).
250    pub path: String,
251    /// A human-readable error message.
252    pub message: String,
253    /// The type of validation error.
254    pub severity: ValidationSeverity,
255}
256
257/// Severity level for validation errors.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ValidationSeverity {
260    /// Fatal error - operation cannot proceed.
261    Error,
262    /// Warning - operation can proceed but with concerns.
263    Warning,
264    /// Informational - no action required.
265    Information,
266}
267
268impl fmt::Display for ValidationSeverity {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            ValidationSeverity::Error => write!(f, "error"),
272            ValidationSeverity::Warning => write!(f, "warning"),
273            ValidationSeverity::Information => write!(f, "information"),
274        }
275    }
276}
277
278/// Errors related to search operations.
279#[derive(Error, Debug)]
280pub enum SearchError {
281    /// The search parameter type is not supported.
282    #[error("unsupported search parameter type: {param_type}")]
283    UnsupportedParameterType {
284        /// Unsupported parameter type label.
285        param_type: String,
286    },
287
288    /// The search modifier is not supported for this parameter type.
289    #[error("unsupported modifier '{modifier}' for parameter type '{param_type}'")]
290    UnsupportedModifier {
291        /// Modifier name (e.g., `contains`).
292        modifier: String,
293        /// Parameter type the modifier was applied to.
294        param_type: String,
295    },
296
297    /// Chained search is not supported by this backend.
298    #[error("chained search not supported: {chain}")]
299    ChainedSearchNotSupported {
300        /// Chain expression that was rejected.
301        chain: String,
302    },
303
304    /// Reverse chaining (_has) is not supported by this backend.
305    #[error("reverse chaining (_has) not supported")]
306    ReverseChainNotSupported,
307
308    /// Include/revinclude not supported.
309    #[error("{operation} not supported by this backend")]
310    IncludeNotSupported {
311        /// Operation name (e.g., `_include`, `_revinclude`).
312        operation: String,
313    },
314
315    /// Too many results to return.
316    #[error("search result limit exceeded: found {count}, maximum is {max}")]
317    TooManyResults {
318        /// Number of matches the query produced.
319        count: usize,
320        /// Maximum allowed result count.
321        max: usize,
322    },
323
324    /// Invalid cursor for pagination.
325    #[error("invalid pagination cursor: {cursor}")]
326    InvalidCursor {
327        /// Cursor value that could not be decoded.
328        cursor: String,
329    },
330
331    /// Search query parsing failed.
332    #[error("failed to parse search query: {message}")]
333    QueryParseError {
334        /// Parser failure detail.
335        message: String,
336    },
337
338    /// Composite search parameter error.
339    #[error("invalid composite search parameter: {message}")]
340    InvalidComposite {
341        /// Human-readable failure detail.
342        message: String,
343    },
344
345    /// Text search not available.
346    #[error("full-text search not available")]
347    TextSearchNotAvailable,
348}
349
350/// Errors related to transactions.
351#[derive(Error, Debug)]
352pub enum TransactionError {
353    /// Transaction timed out.
354    #[error("transaction timed out after {timeout_ms}ms")]
355    Timeout {
356        /// Timeout that elapsed before the transaction completed.
357        timeout_ms: u64,
358    },
359
360    /// Transaction was rolled back.
361    #[error("transaction rolled back: {reason}")]
362    RolledBack {
363        /// Human-readable explanation of why the transaction rolled back.
364        reason: String,
365    },
366
367    /// Transaction is no longer valid (already committed or rolled back).
368    #[error("transaction no longer valid")]
369    InvalidTransaction,
370
371    /// Nested transactions not supported.
372    #[error("nested transactions not supported")]
373    NestedNotSupported,
374
375    /// Bundle processing error.
376    #[error("bundle processing error at entry {index}: {message}")]
377    BundleError {
378        /// Zero-based index of the bundle entry that failed.
379        index: usize,
380        /// Human-readable failure detail.
381        message: String,
382    },
383
384    /// Conditional operation matched multiple resources.
385    #[error("conditional {operation} matched {count} resources, expected at most 1")]
386    MultipleMatches {
387        /// Conditional operation name (e.g., `update`, `delete`).
388        operation: String,
389        /// Number of matching resources found.
390        count: usize,
391    },
392
393    /// Isolation level not supported.
394    #[error("isolation level {level} not supported by this backend")]
395    UnsupportedIsolationLevel {
396        /// Isolation level requested but not supported.
397        level: String,
398    },
399}
400
401/// Errors originating from the database backend.
402#[derive(Error, Debug)]
403pub enum BackendError {
404    /// The backend is currently unavailable.
405    #[error("backend unavailable: {backend_name}")]
406    Unavailable {
407        /// Backend identifier (e.g., `postgres`).
408        backend_name: String,
409        /// Human-readable failure detail.
410        message: String,
411    },
412
413    /// Connection to the backend failed.
414    #[error("connection failed to {backend_name}: {message}")]
415    ConnectionFailed {
416        /// Backend identifier.
417        backend_name: String,
418        /// Underlying connection error message.
419        message: String,
420    },
421
422    /// Connection pool exhausted.
423    #[error("connection pool exhausted for {backend_name}")]
424    PoolExhausted {
425        /// Backend identifier whose pool was exhausted.
426        backend_name: String,
427    },
428
429    /// The requested capability is not supported by this backend.
430    #[error("capability '{capability}' not supported by {backend_name}")]
431    UnsupportedCapability {
432        /// Backend identifier.
433        backend_name: String,
434        /// Capability name that was requested.
435        capability: String,
436    },
437
438    /// Schema migration error.
439    #[error("schema migration failed: {message}")]
440    MigrationError {
441        /// Migration failure detail.
442        message: String,
443    },
444
445    /// Internal backend error.
446    #[error("internal error in {backend_name}: {message}")]
447    Internal {
448        /// Backend identifier.
449        backend_name: String,
450        /// Human-readable failure detail.
451        message: String,
452        /// Underlying error, when one is available.
453        #[source]
454        source: Option<Box<dyn std::error::Error + Send + Sync>>,
455    },
456
457    /// Query execution error.
458    #[error("query execution failed: {message}")]
459    QueryError {
460        /// Failure detail from the database driver.
461        message: String,
462    },
463
464    /// Serialization/deserialization error.
465    #[error("serialization error: {message}")]
466    SerializationError {
467        /// Failure detail from the serializer.
468        message: String,
469    },
470}
471
472/// Errors related to bulk export operations.
473#[derive(Error, Debug)]
474pub enum BulkExportError {
475    /// The export job was not found.
476    #[error("export job not found: {job_id}")]
477    JobNotFound {
478        /// Identifier of the export job.
479        job_id: String,
480    },
481
482    /// The job is in an invalid state for the requested operation.
483    #[error("invalid job state: job {job_id} is {actual}, expected {expected}")]
484    InvalidJobState {
485        /// Identifier of the export job.
486        job_id: String,
487        /// State required for the operation.
488        expected: String,
489        /// State the job is currently in.
490        actual: String,
491    },
492
493    /// The resource type cannot be exported.
494    #[error("resource type '{resource_type}' is not exportable")]
495    TypeNotExportable {
496        /// FHIR resource type that cannot be exported.
497        resource_type: String,
498    },
499
500    /// Invalid export request.
501    #[error("invalid export request: {message}")]
502    InvalidRequest {
503        /// Human-readable explanation of the failure.
504        message: String,
505    },
506
507    /// The specified group was not found.
508    #[error("group not found: {group_id}")]
509    GroupNotFound {
510        /// Identifier of the missing group.
511        group_id: String,
512    },
513
514    /// The output format is not supported.
515    #[error("unsupported export format: {format}")]
516    UnsupportedFormat {
517        /// Requested output format.
518        format: String,
519    },
520
521    /// Invalid type filter.
522    #[error("invalid type filter for {resource_type}: {message}")]
523    InvalidTypeFilter {
524        /// FHIR resource type the filter applied to.
525        resource_type: String,
526        /// Human-readable explanation of the failure.
527        message: String,
528    },
529
530    /// The export was cancelled.
531    #[error("export job {job_id} was cancelled")]
532    Cancelled {
533        /// Identifier of the cancelled job.
534        job_id: String,
535    },
536
537    /// Error writing export output.
538    #[error("export write error: {message}")]
539    WriteError {
540        /// Underlying write failure detail.
541        message: String,
542    },
543
544    /// Too many concurrent exports.
545    #[error("too many concurrent exports (maximum: {max_concurrent})")]
546    TooManyConcurrentExports {
547        /// Configured concurrency cap.
548        max_concurrent: u32,
549    },
550
551    /// The worker lease for this job was lost (reclaimed by another worker).
552    #[error("export job {job_id} lease lost (reclaimed by another worker)")]
553    LeaseLost {
554        /// Identifier of the job whose lease was lost.
555        job_id: String,
556    },
557}
558
559/// Errors related to bulk submit operations.
560#[derive(Error, Debug)]
561pub enum BulkSubmitError {
562    /// The submission was not found.
563    #[error("submission not found: {submitter}/{submission_id}")]
564    SubmissionNotFound {
565        /// Submitter identifier.
566        submitter: String,
567        /// Submission identifier.
568        submission_id: String,
569    },
570
571    /// The manifest was not found.
572    #[error("manifest not found: {submission_id}/{manifest_id}")]
573    ManifestNotFound {
574        /// Parent submission identifier.
575        submission_id: String,
576        /// Manifest identifier.
577        manifest_id: String,
578    },
579
580    /// The submission is in an invalid state for the requested operation.
581    #[error("invalid submission state: {submission_id} is {actual}, expected {expected}")]
582    InvalidState {
583        /// Submission identifier.
584        submission_id: String,
585        /// State required for the operation.
586        expected: String,
587        /// State the submission is currently in.
588        actual: String,
589    },
590
591    /// The submission is already complete.
592    #[error("submission {submission_id} is already complete")]
593    AlreadyComplete {
594        /// Submission identifier.
595        submission_id: String,
596    },
597
598    /// The submission was aborted.
599    #[error("submission {submission_id} was aborted: {reason}")]
600    Aborted {
601        /// Submission identifier.
602        submission_id: String,
603        /// Human-readable abort reason.
604        reason: String,
605    },
606
607    /// Maximum errors exceeded.
608    #[error("submission {submission_id} exceeded maximum errors ({max_errors})")]
609    MaxErrorsExceeded {
610        /// Submission identifier.
611        submission_id: String,
612        /// Configured per-submission error cap.
613        max_errors: u32,
614    },
615
616    /// Error parsing NDJSON entry.
617    #[error("parse error at line {line}: {message}")]
618    ParseError {
619        /// 1-based line number where parsing failed.
620        line: u64,
621        /// Parser failure detail.
622        message: String,
623    },
624
625    /// Invalid resource in submission.
626    #[error("invalid resource at line {line}: {message}")]
627    InvalidResource {
628        /// 1-based line number of the invalid resource.
629        line: u64,
630        /// Validation failure detail.
631        message: String,
632    },
633
634    /// Duplicate submission ID.
635    #[error("duplicate submission: {submitter}/{submission_id}")]
636    DuplicateSubmission {
637        /// Submitter identifier.
638        submitter: String,
639        /// Submission identifier that was reused.
640        submission_id: String,
641    },
642
643    /// Error replacing manifest.
644    #[error("cannot replace manifest {manifest_url}: {reason}")]
645    ManifestReplacementError {
646        /// URL of the manifest that could not be replaced.
647        manifest_url: String,
648        /// Human-readable reason for the failure.
649        reason: String,
650    },
651
652    /// Rollback failed.
653    #[error("rollback failed for submission {submission_id}: {message}")]
654    RollbackFailed {
655        /// Submission identifier.
656        submission_id: String,
657        /// Rollback failure detail.
658        message: String,
659    },
660}
661
662/// Result type alias for storage operations.
663pub type StorageResult<T> = Result<T, StorageError>;
664
665/// Result type alias for search operations.
666pub type SearchResult<T> = Result<T, SearchError>;
667
668/// Result type alias for transaction operations.
669pub type TransactionResult<T> = Result<T, TransactionError>;
670
671// Implement conversions from common error types
672
673impl From<serde_json::Error> for StorageError {
674    fn from(err: serde_json::Error) -> Self {
675        StorageError::Backend(BackendError::SerializationError {
676            message: err.to_string(),
677        })
678    }
679}
680
681impl From<std::io::Error> for BackendError {
682    fn from(err: std::io::Error) -> Self {
683        BackendError::Internal {
684            backend_name: "unknown".to_string(),
685            message: err.to_string(),
686            source: Some(Box::new(err)),
687        }
688    }
689}
690
691#[cfg(feature = "sqlite")]
692impl From<rusqlite::Error> for StorageError {
693    fn from(err: rusqlite::Error) -> Self {
694        StorageError::Backend(BackendError::Internal {
695            backend_name: "sqlite".to_string(),
696            message: err.to_string(),
697            source: Some(Box::new(err)),
698        })
699    }
700}
701
702#[cfg(feature = "sqlite")]
703impl From<r2d2::Error> for StorageError {
704    fn from(_err: r2d2::Error) -> Self {
705        StorageError::Backend(BackendError::PoolExhausted {
706            backend_name: "sqlite".to_string(),
707        })
708    }
709}
710
711#[cfg(feature = "postgres")]
712impl From<tokio_postgres::Error> for StorageError {
713    fn from(err: tokio_postgres::Error) -> Self {
714        StorageError::Backend(BackendError::Internal {
715            backend_name: "postgres".to_string(),
716            message: err.to_string(),
717            source: Some(Box::new(err)),
718        })
719    }
720}
721
722#[cfg(feature = "mongodb")]
723impl From<mongodb::error::Error> for StorageError {
724    fn from(err: mongodb::error::Error) -> Self {
725        StorageError::Backend(BackendError::Internal {
726            backend_name: "mongodb".to_string(),
727            message: err.to_string(),
728            source: Some(Box::new(err)),
729        })
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn test_storage_error_display() {
739        let err = StorageError::Resource(ResourceError::NotFound {
740            resource_type: "Patient".to_string(),
741            id: "123".to_string(),
742        });
743        assert_eq!(err.to_string(), "resource not found: Patient/123");
744    }
745
746    #[test]
747    fn test_concurrency_error_display() {
748        let err = ConcurrencyError::VersionConflict {
749            resource_type: "Patient".to_string(),
750            id: "123".to_string(),
751            expected_version: "1".to_string(),
752            actual_version: "2".to_string(),
753        };
754        assert_eq!(err.to_string(), "version conflict: expected 1, found 2");
755    }
756
757    #[test]
758    fn test_tenant_error_display() {
759        let err = TenantError::AccessDenied {
760            tenant_id: TenantId::new("tenant-a"),
761            resource_type: "Patient".to_string(),
762            resource_id: "123".to_string(),
763        };
764        assert!(err.to_string().contains("access denied"));
765    }
766
767    #[test]
768    fn test_search_error_display() {
769        let err = SearchError::UnsupportedModifier {
770            modifier: "contains".to_string(),
771            param_type: "token".to_string(),
772        };
773        assert!(err.to_string().contains("unsupported modifier"));
774    }
775
776    #[test]
777    fn test_validation_severity_display() {
778        assert_eq!(ValidationSeverity::Error.to_string(), "error");
779        assert_eq!(ValidationSeverity::Warning.to_string(), "warning");
780        assert_eq!(ValidationSeverity::Information.to_string(), "information");
781    }
782
783    #[test]
784    fn test_bulk_export_error_display() {
785        let err = BulkExportError::JobNotFound {
786            job_id: "abc-123".to_string(),
787        };
788        assert_eq!(err.to_string(), "export job not found: abc-123");
789
790        let err = BulkExportError::InvalidJobState {
791            job_id: "abc-123".to_string(),
792            expected: "in-progress".to_string(),
793            actual: "complete".to_string(),
794        };
795        assert!(err.to_string().contains("invalid job state"));
796    }
797
798    #[test]
799    fn test_bulk_submit_error_display() {
800        let err = BulkSubmitError::SubmissionNotFound {
801            submitter: "test-system".to_string(),
802            submission_id: "sub-123".to_string(),
803        };
804        assert_eq!(err.to_string(), "submission not found: test-system/sub-123");
805
806        let err = BulkSubmitError::ParseError {
807            line: 42,
808            message: "invalid JSON".to_string(),
809        };
810        assert!(err.to_string().contains("line 42"));
811    }
812
813    #[test]
814    fn test_storage_error_from_bulk_errors() {
815        let export_err = BulkExportError::JobNotFound {
816            job_id: "test".to_string(),
817        };
818        let storage_err: StorageError = export_err.into();
819        assert!(matches!(storage_err, StorageError::BulkExport(_)));
820
821        let submit_err = BulkSubmitError::SubmissionNotFound {
822            submitter: "test".to_string(),
823            submission_id: "123".to_string(),
824        };
825        let storage_err: StorageError = submit_err.into();
826        assert!(matches!(storage_err, StorageError::BulkSubmit(_)));
827    }
828}