1use std::time::Duration;
10
11use tonic::{Code, Status};
12
13use crate::proto::udb::entity::v1::ErrorDetail;
14
15pub const ERROR_DETAIL_TRAILER: &str = "udb-error-detail-bin";
18
19#[derive(Debug, Clone)]
21pub struct UdbError {
22 pub status: Status,
23 pub detail: Option<ErrorDetail>,
24}
25
26impl UdbError {
27 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 pub fn is_retryable(&self) -> bool {
55 match &self.detail {
56 Some(d) => d.retryable,
57 None => matches!(
59 self.status.code(),
60 Code::Unavailable | Code::ResourceExhausted
61 ),
62 }
63 }
64
65 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 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 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 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#[derive(Debug, Clone, Copy)]
124pub struct CallPolicy {
125 pub deadline: Option<Duration>,
126 pub max_attempts: u32,
127 pub base_backoff: Duration,
128 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 pub fn idempotent() -> Self {
147 Self {
148 idempotent: true,
149 ..Default::default()
150 }
151 }
152
153 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 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 pub(crate) fn backoff_for(&self, attempt: u32, err: &UdbError) -> Duration {
192 if let Some(after) = err.retry_after() {
193 return after;
194 }
195 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 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 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}