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