Skip to main content

osproxy_spi/
error.rs

1//! The error type an SPI implementation returns.
2//!
3//! Every variant maps to a stable [`ErrorCode`] and carries shape-only context
4//! (which sources were tried, which partition id) so a failure is diagnosable
5//! from telemetry without reading source (NFR-T5, `docs/02` §4). It never
6//! carries tenant *values*.
7
8use osproxy_core::{ErrorCode, PartitionId};
9use thiserror::Error;
10
11use crate::rules::PartitionKeySpecKind;
12
13/// A failure returned by [`RoutingSpi`] or [`TenancySpi`].
14///
15/// [`RoutingSpi`]: crate::RoutingSpi
16/// [`TenancySpi`]: crate::TenancySpi
17///
18/// # Examples
19///
20/// ```
21/// use osproxy_spi::SpiError;
22/// use osproxy_spi::core::ErrorCode;
23///
24/// let err = SpiError::PlacementBackend { retryable: true };
25/// assert_eq!(err.code(), ErrorCode::PlacementBackendUnavailable);
26/// assert!(err.retryable());
27/// ```
28#[non_exhaustive]
29#[derive(Debug, Error)]
30pub enum SpiError {
31    /// The partition could not be resolved from the request. Reports which
32    /// source kinds were attempted (shape only).
33    #[error("partition could not be resolved (tried: {tried:?})")]
34    PartitionUnresolved {
35        /// The source kinds tried, in order, before giving up.
36        tried: Vec<PartitionKeySpecKind>,
37    },
38
39    /// No placement exists for the resolved partition.
40    #[error("no placement exists for partition")]
41    PlacementMissing {
42        /// The unresolved partition (an id, safe in telemetry).
43        partition: PartitionId,
44    },
45
46    /// The placement-lookup backend was unavailable.
47    #[error("placement lookup backend unavailable (retryable={retryable})")]
48    PlacementBackend {
49        /// Whether the caller may retry the lookup.
50        retryable: bool,
51    },
52
53    /// The request endpoint is not supported for tenancy rewriting in this mode.
54    #[error("endpoint not supported for tenancy rewrite")]
55    UnsupportedEndpoint {
56        /// The endpoint classification that was rejected.
57        endpoint: osproxy_core::EndpointKind,
58    },
59
60    /// An injected field draws its value from a principal attribute that the
61    /// authenticated principal does not carry. A configuration/identity
62    /// mismatch, surfaced as a routing failure rather than silently injecting a
63    /// null (which would corrupt isolation).
64    #[error("principal is missing an attribute required by an injected field")]
65    PrincipalAttrMissing {
66        /// The missing attribute name.
67        attr: String,
68    },
69
70    /// An injected field draws its value from a request header the request does
71    /// not carry. Surfaced as a routing failure rather than injecting a null.
72    #[error("request is missing a header required by an injected field")]
73    HeaderMissing {
74        /// The missing header name.
75        header: String,
76    },
77
78    /// A `SharedIndex` placement was configured with a doc-id rule that does not
79    /// include the partition id, which would allow cross-tenant id collisions
80    /// (`docs/03`). A configuration error, surfaced as a routing failure.
81    #[error("shared-index doc-id rule must reference the partition id")]
82    IdRuleMissingPartition,
83}
84
85impl SpiError {
86    /// The stable [`ErrorCode`] for this failure, for trace attributes and
87    /// `/debug/explain`.
88    #[must_use]
89    pub fn code(&self) -> ErrorCode {
90        match self {
91            Self::PartitionUnresolved { .. } => ErrorCode::PartitionUnresolved,
92            Self::PlacementMissing { .. } => ErrorCode::PlacementMissing,
93            Self::PlacementBackend { .. } => ErrorCode::PlacementBackendUnavailable,
94            // IdRuleMissingPartition is a misconfiguration that prevents safe
95            // routing; reuse the unsupported-endpoint contract code until a
96            // dedicated config code is added (additive, see docs/08 §7).
97            Self::UnsupportedEndpoint { .. }
98            | Self::IdRuleMissingPartition
99            | Self::PrincipalAttrMissing { .. }
100            | Self::HeaderMissing { .. } => ErrorCode::UnsupportedEndpoint,
101        }
102    }
103
104    /// Whether the caller may retry, possibly after re-resolving placement.
105    #[must_use]
106    pub fn retryable(&self) -> bool {
107        matches!(self, Self::PlacementBackend { retryable: true })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn codes_map_to_core_taxonomy() {
117        assert_eq!(
118            SpiError::PartitionUnresolved { tried: vec![] }.code(),
119            ErrorCode::PartitionUnresolved
120        );
121        assert_eq!(
122            SpiError::PlacementMissing {
123                partition: PartitionId::from("p")
124            }
125            .code(),
126            ErrorCode::PlacementMissing
127        );
128    }
129
130    #[test]
131    fn only_backend_unavailable_is_retryable() {
132        assert!(SpiError::PlacementBackend { retryable: true }.retryable());
133        assert!(!SpiError::PlacementBackend { retryable: false }.retryable());
134        assert!(!SpiError::PlacementMissing {
135            partition: PartitionId::from("p")
136        }
137        .retryable());
138    }
139}