Skip to main content

udb_client/
error.rs

1//! Typed errors and retry policy.
2//!
3//! The broker attaches a structured [`ErrorDetail`] to failures in the
4//! `udb-error-detail-bin` trailer. Without decoding it a caller sees only a gRPC
5//! code and a message, and has to pattern-match on error strings to learn whether
6//! a failure is retryable, which capability was missing, or which field was
7//! rejected — exactly the things the broker already said precisely.
8
9use std::time::Duration;
10
11use tonic::{Code, Status};
12
13use crate::proto::udb::entity::v1::ErrorDetail;
14
15/// The metadata key the broker uses. The `-bin` suffix tells gRPC the value is
16/// raw bytes rather than ASCII, so tonic base64-decodes it for us.
17pub const ERROR_DETAIL_TRAILER: &str = "udb-error-detail-bin";
18
19/// A `Status` plus the broker's structured detail, when it sent one.
20#[derive(Debug, Clone)]
21pub struct UdbError {
22    pub status: Status,
23    pub detail: Option<ErrorDetail>,
24}
25
26impl UdbError {
27    /// Decode the trailer, if present and well-formed.
28    ///
29    /// A malformed trailer is treated as absent rather than as a new failure: the
30    /// call already failed and the caller needs the original status, not a
31    /// decoding complaint layered over it.
32    pub fn from_status(status: Status) -> Self {
33        let detail = status
34            .metadata()
35            .get_bin(ERROR_DETAIL_TRAILER)
36            .and_then(|v| v.to_bytes().ok())
37            .and_then(|bytes| <ErrorDetail as prost::Message>::decode(bytes).ok());
38        Self { status, detail }
39    }
40
41    pub fn code(&self) -> Code {
42        self.status.code()
43    }
44
45    pub fn message(&self) -> &str {
46        self.status.message()
47    }
48
49    /// Whether the BROKER said this is retryable.
50    ///
51    /// Trusted over any code-based guess: the broker knows whether it got far
52    /// enough to have side effects, and a client cannot infer that from
53    /// `UNAVAILABLE` alone.
54    pub fn is_retryable(&self) -> bool {
55        match &self.detail {
56            Some(d) => d.retryable,
57            // No detail: fall back to codes that cannot have applied a mutation.
58            None => matches!(
59                self.status.code(),
60                Code::Unavailable | Code::ResourceExhausted
61            ),
62        }
63    }
64
65    /// How long the broker asked us to wait, if it said.
66    pub fn retry_after(&self) -> Option<Duration> {
67        let ms = self.detail.as_ref()?.retry_after_ms;
68        (ms > 0).then(|| Duration::from_millis(ms as u64))
69    }
70
71    /// The capability the deployment is missing, for a capability refusal.
72    pub fn capability_required(&self) -> Option<&str> {
73        let c = self.detail.as_ref()?.capability_required.as_str();
74        (!c.is_empty()).then_some(c)
75    }
76
77    /// The correlation id to quote in a bug report or a support ticket.
78    pub fn correlation_id(&self) -> Option<&str> {
79        let c = self.detail.as_ref()?.correlation_id.as_str();
80        (!c.is_empty()).then_some(c)
81    }
82
83    /// Per-field rejections, for a validation failure.
84    pub fn field_violations(&self) -> &[crate::proto::udb::entity::v1::ErrorFieldViolation] {
85        self.detail
86            .as_ref()
87            .map(|d| d.field_violations.as_slice())
88            .unwrap_or_default()
89    }
90}
91
92impl std::fmt::Display for UdbError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "{}: {}", self.status.code(), self.status.message())?;
95        if let Some(detail) = &self.detail {
96            if !detail.backend.is_empty() {
97                write!(f, " [backend={}", detail.backend)?;
98                if !detail.operation.is_empty() {
99                    write!(f, " op={}", detail.operation)?;
100                }
101                write!(f, "]")?;
102            }
103            if !detail.capability_required.is_empty() {
104                write!(f, " (requires {})", detail.capability_required)?;
105            }
106        }
107        Ok(())
108    }
109}
110
111impl std::error::Error for UdbError {}
112
113impl From<Status> for UdbError {
114    fn from(status: Status) -> Self {
115        Self::from_status(status)
116    }
117}
118
119/// Per-call deadline and retry policy.
120///
121/// Retries are bounded and only ever applied where the caller has said the
122/// operation is safe to repeat — see [`CallPolicy::idempotent`].
123#[derive(Debug, Clone, Copy)]
124pub struct CallPolicy {
125    pub deadline: Option<Duration>,
126    pub max_attempts: u32,
127    pub base_backoff: Duration,
128    /// Whether the operation may be safely repeated. Defaults to `false`: a
129    /// blanket retry on a mutation is how one payment becomes two.
130    pub idempotent: bool,
131}
132
133impl Default for CallPolicy {
134    fn default() -> Self {
135        Self {
136            deadline: Some(Duration::from_secs(30)),
137            max_attempts: 3,
138            base_backoff: Duration::from_millis(100),
139            idempotent: false,
140        }
141    }
142}
143
144impl CallPolicy {
145    /// A read: safe to repeat.
146    pub fn idempotent() -> Self {
147        Self {
148            idempotent: true,
149            ..Default::default()
150        }
151    }
152
153    /// The policy the CONTRACT implies for one RPC path.
154    ///
155    /// This replaced a hand-written per-method judgement, which was wrong in both
156    /// directions: it refused to retry `Upsert`, `Update` and `Delete` even
157    /// though the broker declares them replayable, costing availability on a
158    /// transient failure the broker was happy to see again.
159    ///
160    /// Deciding from the descriptor instead of a method name is the point of the
161    /// `operation_kind` annotation. An unknown path yields the conservative
162    /// default (no retries).
163    pub fn from_contract(path: &str) -> Self {
164        if crate::generated_rpcs::is_retry_safe(path) {
165            Self::idempotent()
166        } else {
167            Self::default()
168        }
169    }
170
171    /// No retries at all.
172    pub fn once() -> Self {
173        Self {
174            max_attempts: 1,
175            ..Default::default()
176        }
177    }
178
179    pub fn with_deadline(mut self, deadline: Duration) -> Self {
180        self.deadline = Some(deadline);
181        self
182    }
183
184    pub fn with_max_attempts(mut self, attempts: u32) -> Self {
185        self.max_attempts = attempts.max(1);
186        self
187    }
188
189    /// Backoff before `attempt` (1-based), honouring the broker's `retry_after`
190    /// when it gave one.
191    pub(crate) fn backoff_for(&self, attempt: u32, err: &UdbError) -> Duration {
192        if let Some(after) = err.retry_after() {
193            return after;
194        }
195        // Exponential, capped. No jitter here: callers that need decorrelated
196        // retries across a fleet should drive their own loop, and inventing
197        // randomness inside a client makes failures harder to reproduce.
198        let exp = self
199            .base_backoff
200            .saturating_mul(1u32 << attempt.min(6).saturating_sub(1));
201        exp.min(Duration::from_secs(5))
202    }
203
204    /// Whether another attempt is permitted.
205    pub(crate) fn should_retry(&self, attempt: u32, err: &UdbError) -> bool {
206        self.idempotent && attempt < self.max_attempts && err.is_retryable()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn status_without_detail(code: Code) -> UdbError {
215        UdbError::from_status(Status::new(code, "boom"))
216    }
217
218    #[test]
219    fn missing_trailer_falls_back_to_safe_codes() {
220        assert!(status_without_detail(Code::Unavailable).is_retryable());
221        assert!(status_without_detail(Code::ResourceExhausted).is_retryable());
222        assert!(
223            !status_without_detail(Code::InvalidArgument).is_retryable(),
224            "a rejected argument will be rejected again"
225        );
226        assert!(
227            !status_without_detail(Code::Internal).is_retryable(),
228            "INTERNAL may have applied a mutation; never assume repeatable"
229        );
230    }
231
232    #[test]
233    fn malformed_trailer_is_treated_as_absent() {
234        let mut status = Status::new(Code::Internal, "boom");
235        status.metadata_mut().insert_bin(
236            ERROR_DETAIL_TRAILER,
237            tonic::metadata::MetadataValue::from_bytes(b"not-a-protobuf-at-all"),
238        );
239        let err = UdbError::from_status(status);
240        // Decoding failure must not mask the original status.
241        assert_eq!(err.code(), Code::Internal);
242        assert_eq!(err.message(), "boom");
243    }
244
245    #[test]
246    fn broker_detail_overrides_the_code_guess() {
247        let detail = ErrorDetail {
248            retryable: true,
249            retry_after_ms: 250,
250            capability_required: "postgres_backend".into(),
251            correlation_id: "corr-9".into(),
252            ..Default::default()
253        };
254        let mut status = Status::new(Code::Internal, "boom");
255        let mut buf = Vec::new();
256        prost::Message::encode(&detail, &mut buf).expect("encode");
257        status.metadata_mut().insert_bin(
258            ERROR_DETAIL_TRAILER,
259            tonic::metadata::MetadataValue::from_bytes(&buf),
260        );
261
262        let err = UdbError::from_status(status);
263        assert!(
264            err.is_retryable(),
265            "INTERNAL, but the broker said retryable"
266        );
267        assert_eq!(err.retry_after(), Some(Duration::from_millis(250)));
268        assert_eq!(err.capability_required(), Some("postgres_backend"));
269        assert_eq!(err.correlation_id(), Some("corr-9"));
270    }
271
272    #[test]
273    fn mutations_are_not_retried_by_default() {
274        let err = status_without_detail(Code::Unavailable);
275        assert!(
276            !CallPolicy::default().should_retry(1, &err),
277            "default policy must not repeat a possible mutation"
278        );
279        assert!(CallPolicy::idempotent().should_retry(1, &err));
280    }
281
282    #[test]
283    fn retry_stops_at_max_attempts() {
284        let err = status_without_detail(Code::Unavailable);
285        let policy = CallPolicy::idempotent().with_max_attempts(2);
286        assert!(policy.should_retry(1, &err));
287        assert!(!policy.should_retry(2, &err), "attempt 2 of 2 is the last");
288    }
289
290    #[test]
291    fn backoff_prefers_the_brokers_retry_after() {
292        let detail = ErrorDetail {
293            retryable: true,
294            retry_after_ms: 1234,
295            ..Default::default()
296        };
297        let err = UdbError {
298            status: Status::new(Code::Unavailable, "boom"),
299            detail: Some(detail),
300        };
301        assert_eq!(
302            CallPolicy::idempotent().backoff_for(3, &err),
303            Duration::from_millis(1234)
304        );
305    }
306
307    #[test]
308    fn backoff_grows_and_is_capped() {
309        let err = status_without_detail(Code::Unavailable);
310        let p = CallPolicy::idempotent();
311        assert_eq!(p.backoff_for(1, &err), Duration::from_millis(100));
312        assert_eq!(p.backoff_for(2, &err), Duration::from_millis(200));
313        assert_eq!(p.backoff_for(3, &err), Duration::from_millis(400));
314        assert!(p.backoff_for(20, &err) <= Duration::from_secs(5));
315    }
316}