Skip to main content

osproxy_engine/
error.rs

1//! The top-level request-path error.
2//!
3//! Built from each stage's sub-error so the decision chain
4//! (principal → partition → placement → epoch → upstream) is preserved for
5//! diagnosis without source reading (NFR-T5, `docs/02` §4). Carries codes and
6//! shapes only, never tenant values.
7
8use osproxy_core::ErrorCode;
9use osproxy_rewrite::RewriteError;
10use osproxy_sink::SinkError;
11use osproxy_spi::SpiError;
12use thiserror::Error;
13
14/// A failure anywhere on the request path.
15#[non_exhaustive]
16#[derive(Debug, Error)]
17pub enum RequestError {
18    /// Routing (partition resolution / placement) failed.
19    #[error("routing failed: {0}")]
20    Spi(#[from] SpiError),
21
22    /// A body transform failed (malformed document, reserved-field collision).
23    #[error("rewrite failed: {0}")]
24    Rewrite(#[from] RewriteError),
25
26    /// The write could not be delivered or was rejected upstream.
27    #[error("sink failed: {0}")]
28    Sink(#[from] SinkError),
29
30    /// The write resolved against a placement epoch no longer current for a
31    /// migrating partition: the migration write gate held it (`docs/06` §2).
32    /// Retryable, the client re-resolves against the new placement.
33    #[error("stale placement epoch {stamped} for a migrating partition")]
34    StaleEpoch {
35        /// The epoch the rejected decision was stamped with (an id, not data).
36        stamped: osproxy_core::Epoch,
37    },
38
39    /// An internal invariant was violated, a bug, not a client or upstream
40    /// fault. Carries a static reason (never tenant data) for the operator/LLM.
41    #[error("internal invariant violated: {reason}")]
42    Internal {
43        /// A short, value-free description of the violated invariant.
44        reason: &'static str,
45    },
46
47    /// A scroll/PIT cursor could not be resolved to its pinned cluster, its
48    /// affinity envelope is absent, malformed, or fails its signature. The client
49    /// must re-issue the originating search (`docs/03` §6).
50    #[error("cursor unresolvable: {reason}")]
51    Cursor {
52        /// A short, value-free reason (e.g. `"missing"`, `"bad signature"`).
53        reason: &'static str,
54    },
55
56    /// The request body exceeded a size cap (e.g. a single `_bulk` line over the
57    /// per-op limit). A client error (`413`), not an internal fault: the client
58    /// must split or shrink the body.
59    #[error("payload too large: {reason}")]
60    PayloadTooLarge {
61        /// A short, value-free description of the limit that was exceeded.
62        reason: &'static str,
63    },
64}
65
66impl RequestError {
67    /// The stable [`ErrorCode`] for this failure, surfaced into the trace and
68    /// `/debug/explain`.
69    #[must_use]
70    pub fn code(&self) -> ErrorCode {
71        match self {
72            Self::Spi(e) => e.code(),
73            Self::Sink(e) => e.code(),
74            Self::StaleEpoch { .. } => ErrorCode::StaleEpoch,
75            // A malformed body or reserved-field collision is an unsupported /
76            // rejected request shape; reuse the unsupported-endpoint code until
77            // a dedicated rewrite code is added (additive, docs/08 §7).
78            Self::Rewrite(_) | Self::Internal { .. } => ErrorCode::UnsupportedEndpoint,
79            Self::Cursor { .. } => ErrorCode::CursorUnresolvable,
80            Self::PayloadTooLarge { .. } => ErrorCode::PayloadTooLarge,
81        }
82    }
83
84    /// Whether the caller may retry.
85    #[must_use]
86    pub fn retryable(&self) -> bool {
87        match self {
88            Self::Spi(e) => e.retryable(),
89            Self::Sink(e) => e.retryable(),
90            // A stale epoch is retryable: the retry re-resolves the placement.
91            Self::StaleEpoch { .. } => true,
92            // Malformed body, internal bug, an unresolvable cursor, or an
93            // over-cap body: a blind retry cannot help (the cursor case wants a
94            // re-issued search; the over-cap case wants a smaller body).
95            Self::Rewrite(_)
96            | Self::Internal { .. }
97            | Self::Cursor { .. }
98            | Self::PayloadTooLarge { .. } => false,
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use osproxy_core::PartitionId;
107
108    #[test]
109    fn spi_error_code_propagates() {
110        let err: RequestError = SpiError::PlacementMissing {
111            partition: PartitionId::from("p"),
112        }
113        .into();
114        assert_eq!(err.code(), ErrorCode::PlacementMissing);
115        assert!(!err.retryable());
116    }
117
118    #[test]
119    fn sink_error_retryability_propagates() {
120        let err: RequestError = SinkError::Transport { kind: "reset" }.into();
121        assert_eq!(err.code(), ErrorCode::UpstreamFailed);
122        assert!(err.retryable());
123    }
124
125    #[test]
126    fn rewrite_and_internal_are_terminal() {
127        let err: RequestError = RewriteError::NotAnObject.into();
128        assert_eq!(err.code(), ErrorCode::UnsupportedEndpoint);
129        assert!(!err.retryable());
130        assert!(!RequestError::Internal { reason: "x" }.retryable());
131    }
132}