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}