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
7// Error enum variant fields are self-documenting via their #[error(...)] messages
8#![allow(missing_docs)]
9
10use std::fmt;
11
12use thiserror::Error;
13
14use crate::tenant::TenantId;
15
16/// The primary error type for all storage operations.
17///
18/// This enum encompasses all possible errors that can occur during persistence
19/// operations, organized by category.
20#[derive(Error, Debug)]
21pub enum StorageError {
22    /// Resource state errors
23    #[error(transparent)]
24    Resource(#[from] ResourceError),
25
26    /// Concurrency and versioning errors
27    #[error(transparent)]
28    Concurrency(#[from] ConcurrencyError),
29
30    /// Tenant isolation errors
31    #[error(transparent)]
32    Tenant(#[from] TenantError),
33
34    /// Validation errors
35    #[error(transparent)]
36    Validation(#[from] ValidationError),
37
38    /// Search operation errors
39    #[error(transparent)]
40    Search(#[from] SearchError),
41
42    /// Transaction errors
43    #[error(transparent)]
44    Transaction(#[from] TransactionError),
45
46    /// Backend-specific errors
47    #[error(transparent)]
48    Backend(#[from] BackendError),
49
50    /// Bulk export errors
51    #[error(transparent)]
52    BulkExport(#[from] BulkExportError),
53
54    /// Bulk submit errors
55    #[error(transparent)]
56    BulkSubmit(#[from] BulkSubmitError),
57}
58
59/// Errors related to resource state.
60#[derive(Error, Debug)]
61pub enum ResourceError {
62    /// The requested resource was not found.
63    #[error("resource not found: {resource_type}/{id}")]
64    NotFound { resource_type: String, id: String },
65
66    /// A resource with the given ID already exists.
67    #[error("resource already exists: {resource_type}/{id}")]
68    AlreadyExists { resource_type: String, id: String },
69
70    /// The resource has been deleted (HTTP 410 Gone).
71    #[error("resource deleted: {resource_type}/{id}")]
72    Gone {
73        resource_type: String,
74        id: String,
75        deleted_at: Option<chrono::DateTime<chrono::Utc>>,
76    },
77
78    /// The requested version of the resource was not found.
79    #[error("version not found: {resource_type}/{id}/_history/{version_id}")]
80    VersionNotFound {
81        resource_type: String,
82        id: String,
83        version_id: String,
84    },
85}
86
87/// Errors related to concurrency control.
88#[derive(Error, Debug)]
89pub enum ConcurrencyError {
90    /// Version conflict detected during optimistic locking.
91    #[error("version conflict: expected {expected_version}, found {actual_version}")]
92    VersionConflict {
93        resource_type: String,
94        id: String,
95        expected_version: String,
96        actual_version: String,
97    },
98
99    /// Optimistic lock failure (If-Match precondition failed).
100    #[error("optimistic lock failure: resource {resource_type}/{id} has been modified")]
101    OptimisticLockFailure {
102        resource_type: String,
103        id: String,
104        expected_etag: String,
105        actual_etag: Option<String>,
106    },
107
108    /// Deadlock detected during pessimistic locking.
109    #[error("deadlock detected while accessing {resource_type}/{id}")]
110    Deadlock { resource_type: String, id: String },
111
112    /// Lock acquisition timed out.
113    #[error("lock timeout after {timeout_ms}ms for {resource_type}/{id}")]
114    LockTimeout {
115        resource_type: String,
116        id: String,
117        timeout_ms: u64,
118    },
119}
120
121/// Errors related to tenant isolation.
122#[derive(Error, Debug)]
123pub enum TenantError {
124    /// Access to resource denied for the current tenant.
125    #[error("access denied: tenant {tenant_id} cannot access {resource_type}/{resource_id}")]
126    AccessDenied {
127        tenant_id: TenantId,
128        resource_type: String,
129        resource_id: String,
130    },
131
132    /// The specified tenant does not exist or is invalid.
133    #[error("invalid tenant: {tenant_id}")]
134    InvalidTenant { tenant_id: TenantId },
135
136    /// Tenant is suspended and cannot perform operations.
137    #[error("tenant suspended: {tenant_id}")]
138    TenantSuspended { tenant_id: TenantId },
139
140    /// Cross-tenant reference not allowed.
141    #[error(
142        "cross-tenant reference not allowed: resource in tenant {source_tenant} references resource in tenant {target_tenant}"
143    )]
144    CrossTenantReference {
145        source_tenant: TenantId,
146        target_tenant: TenantId,
147        reference: String,
148    },
149
150    /// Operation not permitted for tenant.
151    #[error("operation {operation} not permitted for tenant {tenant_id}")]
152    OperationNotPermitted {
153        tenant_id: TenantId,
154        operation: String,
155    },
156}
157
158/// Errors related to resource validation.
159#[derive(Error, Debug)]
160pub enum ValidationError {
161    /// The resource failed validation.
162    #[error("invalid resource: {message}")]
163    InvalidResource {
164        message: String,
165        details: Vec<ValidationDetail>,
166    },
167
168    /// The search parameter is invalid.
169    #[error("invalid search parameter: {parameter}")]
170    InvalidSearchParameter { parameter: String, message: String },
171
172    /// The resource type is not supported.
173    #[error("unsupported resource type: {resource_type}")]
174    UnsupportedResourceType { resource_type: String },
175
176    /// Missing required field.
177    #[error("missing required field: {field}")]
178    MissingRequiredField { field: String },
179
180    /// Invalid reference format.
181    #[error("invalid reference: {reference}")]
182    InvalidReference { reference: String, message: String },
183}
184
185/// Detailed validation error information.
186#[derive(Debug, Clone)]
187pub struct ValidationDetail {
188    /// The path to the field with the error (FHIRPath expression).
189    pub path: String,
190    /// A human-readable error message.
191    pub message: String,
192    /// The type of validation error.
193    pub severity: ValidationSeverity,
194}
195
196/// Severity level for validation errors.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ValidationSeverity {
199    /// Fatal error - operation cannot proceed.
200    Error,
201    /// Warning - operation can proceed but with concerns.
202    Warning,
203    /// Informational - no action required.
204    Information,
205}
206
207impl fmt::Display for ValidationSeverity {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            ValidationSeverity::Error => write!(f, "error"),
211            ValidationSeverity::Warning => write!(f, "warning"),
212            ValidationSeverity::Information => write!(f, "information"),
213        }
214    }
215}
216
217/// Errors related to search operations.
218#[derive(Error, Debug)]
219pub enum SearchError {
220    /// The search parameter type is not supported.
221    #[error("unsupported search parameter type: {param_type}")]
222    UnsupportedParameterType { param_type: String },
223
224    /// The search modifier is not supported for this parameter type.
225    #[error("unsupported modifier '{modifier}' for parameter type '{param_type}'")]
226    UnsupportedModifier {
227        modifier: String,
228        param_type: String,
229    },
230
231    /// Chained search is not supported by this backend.
232    #[error("chained search not supported: {chain}")]
233    ChainedSearchNotSupported { chain: String },
234
235    /// Reverse chaining (_has) is not supported by this backend.
236    #[error("reverse chaining (_has) not supported")]
237    ReverseChainNotSupported,
238
239    /// Include/revinclude not supported.
240    #[error("{operation} not supported by this backend")]
241    IncludeNotSupported { operation: String },
242
243    /// Too many results to return.
244    #[error("search result limit exceeded: found {count}, maximum is {max}")]
245    TooManyResults { count: usize, max: usize },
246
247    /// Invalid cursor for pagination.
248    #[error("invalid pagination cursor: {cursor}")]
249    InvalidCursor { cursor: String },
250
251    /// Search query parsing failed.
252    #[error("failed to parse search query: {message}")]
253    QueryParseError { message: String },
254
255    /// Composite search parameter error.
256    #[error("invalid composite search parameter: {message}")]
257    InvalidComposite { message: String },
258
259    /// Text search not available.
260    #[error("full-text search not available")]
261    TextSearchNotAvailable,
262}
263
264/// Errors related to transactions.
265#[derive(Error, Debug)]
266pub enum TransactionError {
267    /// Transaction timed out.
268    #[error("transaction timed out after {timeout_ms}ms")]
269    Timeout { timeout_ms: u64 },
270
271    /// Transaction was rolled back.
272    #[error("transaction rolled back: {reason}")]
273    RolledBack { reason: String },
274
275    /// Transaction is no longer valid (already committed or rolled back).
276    #[error("transaction no longer valid")]
277    InvalidTransaction,
278
279    /// Nested transactions not supported.
280    #[error("nested transactions not supported")]
281    NestedNotSupported,
282
283    /// Bundle processing error.
284    #[error("bundle processing error at entry {index}: {message}")]
285    BundleError { index: usize, message: String },
286
287    /// Conditional operation matched multiple resources.
288    #[error("conditional {operation} matched {count} resources, expected at most 1")]
289    MultipleMatches { operation: String, count: usize },
290
291    /// Isolation level not supported.
292    #[error("isolation level {level} not supported by this backend")]
293    UnsupportedIsolationLevel { level: String },
294}
295
296/// Errors originating from the database backend.
297#[derive(Error, Debug)]
298pub enum BackendError {
299    /// The backend is currently unavailable.
300    #[error("backend unavailable: {backend_name}")]
301    Unavailable {
302        backend_name: String,
303        message: String,
304    },
305
306    /// Connection to the backend failed.
307    #[error("connection failed to {backend_name}: {message}")]
308    ConnectionFailed {
309        backend_name: String,
310        message: String,
311    },
312
313    /// Connection pool exhausted.
314    #[error("connection pool exhausted for {backend_name}")]
315    PoolExhausted { backend_name: String },
316
317    /// The requested capability is not supported by this backend.
318    #[error("capability '{capability}' not supported by {backend_name}")]
319    UnsupportedCapability {
320        backend_name: String,
321        capability: String,
322    },
323
324    /// Schema migration error.
325    #[error("schema migration failed: {message}")]
326    MigrationError { message: String },
327
328    /// Internal backend error.
329    #[error("internal error in {backend_name}: {message}")]
330    Internal {
331        backend_name: String,
332        message: String,
333        #[source]
334        source: Option<Box<dyn std::error::Error + Send + Sync>>,
335    },
336
337    /// Query execution error.
338    #[error("query execution failed: {message}")]
339    QueryError { message: String },
340
341    /// Serialization/deserialization error.
342    #[error("serialization error: {message}")]
343    SerializationError { message: String },
344}
345
346/// Errors related to bulk export operations.
347#[derive(Error, Debug)]
348pub enum BulkExportError {
349    /// The export job was not found.
350    #[error("export job not found: {job_id}")]
351    JobNotFound { job_id: String },
352
353    /// The job is in an invalid state for the requested operation.
354    #[error("invalid job state: job {job_id} is {actual}, expected {expected}")]
355    InvalidJobState {
356        job_id: String,
357        expected: String,
358        actual: String,
359    },
360
361    /// The resource type cannot be exported.
362    #[error("resource type '{resource_type}' is not exportable")]
363    TypeNotExportable { resource_type: String },
364
365    /// Invalid export request.
366    #[error("invalid export request: {message}")]
367    InvalidRequest { message: String },
368
369    /// The specified group was not found.
370    #[error("group not found: {group_id}")]
371    GroupNotFound { group_id: String },
372
373    /// The output format is not supported.
374    #[error("unsupported export format: {format}")]
375    UnsupportedFormat { format: String },
376
377    /// Invalid type filter.
378    #[error("invalid type filter for {resource_type}: {message}")]
379    InvalidTypeFilter {
380        resource_type: String,
381        message: String,
382    },
383
384    /// The export was cancelled.
385    #[error("export job {job_id} was cancelled")]
386    Cancelled { job_id: String },
387
388    /// Error writing export output.
389    #[error("export write error: {message}")]
390    WriteError { message: String },
391
392    /// Too many concurrent exports.
393    #[error("too many concurrent exports (maximum: {max_concurrent})")]
394    TooManyConcurrentExports { max_concurrent: u32 },
395}
396
397/// Errors related to bulk submit operations.
398#[derive(Error, Debug)]
399pub enum BulkSubmitError {
400    /// The submission was not found.
401    #[error("submission not found: {submitter}/{submission_id}")]
402    SubmissionNotFound {
403        submitter: String,
404        submission_id: String,
405    },
406
407    /// The manifest was not found.
408    #[error("manifest not found: {submission_id}/{manifest_id}")]
409    ManifestNotFound {
410        submission_id: String,
411        manifest_id: String,
412    },
413
414    /// The submission is in an invalid state for the requested operation.
415    #[error("invalid submission state: {submission_id} is {actual}, expected {expected}")]
416    InvalidState {
417        submission_id: String,
418        expected: String,
419        actual: String,
420    },
421
422    /// The submission is already complete.
423    #[error("submission {submission_id} is already complete")]
424    AlreadyComplete { submission_id: String },
425
426    /// The submission was aborted.
427    #[error("submission {submission_id} was aborted: {reason}")]
428    Aborted {
429        submission_id: String,
430        reason: String,
431    },
432
433    /// Maximum errors exceeded.
434    #[error("submission {submission_id} exceeded maximum errors ({max_errors})")]
435    MaxErrorsExceeded {
436        submission_id: String,
437        max_errors: u32,
438    },
439
440    /// Error parsing NDJSON entry.
441    #[error("parse error at line {line}: {message}")]
442    ParseError { line: u64, message: String },
443
444    /// Invalid resource in submission.
445    #[error("invalid resource at line {line}: {message}")]
446    InvalidResource { line: u64, message: String },
447
448    /// Duplicate submission ID.
449    #[error("duplicate submission: {submitter}/{submission_id}")]
450    DuplicateSubmission {
451        submitter: String,
452        submission_id: String,
453    },
454
455    /// Error replacing manifest.
456    #[error("cannot replace manifest {manifest_url}: {reason}")]
457    ManifestReplacementError {
458        manifest_url: String,
459        reason: String,
460    },
461
462    /// Rollback failed.
463    #[error("rollback failed for submission {submission_id}: {message}")]
464    RollbackFailed {
465        submission_id: String,
466        message: String,
467    },
468}
469
470/// Result type alias for storage operations.
471pub type StorageResult<T> = Result<T, StorageError>;
472
473/// Result type alias for search operations.
474pub type SearchResult<T> = Result<T, SearchError>;
475
476/// Result type alias for transaction operations.
477pub type TransactionResult<T> = Result<T, TransactionError>;
478
479// Implement conversions from common error types
480
481impl From<serde_json::Error> for StorageError {
482    fn from(err: serde_json::Error) -> Self {
483        StorageError::Backend(BackendError::SerializationError {
484            message: err.to_string(),
485        })
486    }
487}
488
489impl From<std::io::Error> for BackendError {
490    fn from(err: std::io::Error) -> Self {
491        BackendError::Internal {
492            backend_name: "unknown".to_string(),
493            message: err.to_string(),
494            source: Some(Box::new(err)),
495        }
496    }
497}
498
499#[cfg(feature = "sqlite")]
500impl From<rusqlite::Error> for StorageError {
501    fn from(err: rusqlite::Error) -> Self {
502        StorageError::Backend(BackendError::Internal {
503            backend_name: "sqlite".to_string(),
504            message: err.to_string(),
505            source: Some(Box::new(err)),
506        })
507    }
508}
509
510#[cfg(feature = "sqlite")]
511impl From<r2d2::Error> for StorageError {
512    fn from(_err: r2d2::Error) -> Self {
513        StorageError::Backend(BackendError::PoolExhausted {
514            backend_name: "sqlite".to_string(),
515        })
516    }
517}
518
519#[cfg(feature = "postgres")]
520impl From<tokio_postgres::Error> for StorageError {
521    fn from(err: tokio_postgres::Error) -> Self {
522        StorageError::Backend(BackendError::Internal {
523            backend_name: "postgres".to_string(),
524            message: err.to_string(),
525            source: Some(Box::new(err)),
526        })
527    }
528}
529
530#[cfg(feature = "mongodb")]
531impl From<mongodb::error::Error> for StorageError {
532    fn from(err: mongodb::error::Error) -> Self {
533        StorageError::Backend(BackendError::Internal {
534            backend_name: "mongodb".to_string(),
535            message: err.to_string(),
536            source: Some(Box::new(err)),
537        })
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_storage_error_display() {
547        let err = StorageError::Resource(ResourceError::NotFound {
548            resource_type: "Patient".to_string(),
549            id: "123".to_string(),
550        });
551        assert_eq!(err.to_string(), "resource not found: Patient/123");
552    }
553
554    #[test]
555    fn test_concurrency_error_display() {
556        let err = ConcurrencyError::VersionConflict {
557            resource_type: "Patient".to_string(),
558            id: "123".to_string(),
559            expected_version: "1".to_string(),
560            actual_version: "2".to_string(),
561        };
562        assert_eq!(err.to_string(), "version conflict: expected 1, found 2");
563    }
564
565    #[test]
566    fn test_tenant_error_display() {
567        let err = TenantError::AccessDenied {
568            tenant_id: TenantId::new("tenant-a"),
569            resource_type: "Patient".to_string(),
570            resource_id: "123".to_string(),
571        };
572        assert!(err.to_string().contains("access denied"));
573    }
574
575    #[test]
576    fn test_search_error_display() {
577        let err = SearchError::UnsupportedModifier {
578            modifier: "contains".to_string(),
579            param_type: "token".to_string(),
580        };
581        assert!(err.to_string().contains("unsupported modifier"));
582    }
583
584    #[test]
585    fn test_validation_severity_display() {
586        assert_eq!(ValidationSeverity::Error.to_string(), "error");
587        assert_eq!(ValidationSeverity::Warning.to_string(), "warning");
588        assert_eq!(ValidationSeverity::Information.to_string(), "information");
589    }
590
591    #[test]
592    fn test_bulk_export_error_display() {
593        let err = BulkExportError::JobNotFound {
594            job_id: "abc-123".to_string(),
595        };
596        assert_eq!(err.to_string(), "export job not found: abc-123");
597
598        let err = BulkExportError::InvalidJobState {
599            job_id: "abc-123".to_string(),
600            expected: "in-progress".to_string(),
601            actual: "complete".to_string(),
602        };
603        assert!(err.to_string().contains("invalid job state"));
604    }
605
606    #[test]
607    fn test_bulk_submit_error_display() {
608        let err = BulkSubmitError::SubmissionNotFound {
609            submitter: "test-system".to_string(),
610            submission_id: "sub-123".to_string(),
611        };
612        assert_eq!(err.to_string(), "submission not found: test-system/sub-123");
613
614        let err = BulkSubmitError::ParseError {
615            line: 42,
616            message: "invalid JSON".to_string(),
617        };
618        assert!(err.to_string().contains("line 42"));
619    }
620
621    #[test]
622    fn test_storage_error_from_bulk_errors() {
623        let export_err = BulkExportError::JobNotFound {
624            job_id: "test".to_string(),
625        };
626        let storage_err: StorageError = export_err.into();
627        assert!(matches!(storage_err, StorageError::BulkExport(_)));
628
629        let submit_err = BulkSubmitError::SubmissionNotFound {
630            submitter: "test".to_string(),
631            submission_id: "123".to_string(),
632        };
633        let storage_err: StorageError = submit_err.into();
634        assert!(matches!(storage_err, StorageError::BulkSubmit(_)));
635    }
636}