1use crate::heddle::api::common::{
4 AccountBillingLock, AccountBillingLockReason, CallFailure, CursorFailure, ErrorDetail,
5 ErrorReason, PolicyDenial, RetryAdvice, StreamFailure, error_detail,
6};
7use prost::Message;
8
9pub const ACCOUNT_BILLING_LOCK_POLICY_ID: &str = "account_locked_billing";
11
12pub fn account_billing_lock_error_detail(
15 resource: impl Into<String>,
16 billing_lock: AccountBillingLock,
17) -> ErrorDetail {
18 let rule = match AccountBillingLockReason::try_from(billing_lock.reason) {
19 Ok(AccountBillingLockReason::OverFreeCapWithoutPaidPlan) => {
20 "over_free_cap_without_paid_plan"
21 }
22 _ => "account_billing_lock",
23 };
24 ErrorDetail {
25 reason: ErrorReason::PolicyDenied as i32,
26 resource: resource.into(),
27 field: String::new(),
28 context: Some(error_detail::Context::Policy(PolicyDenial {
29 policy_id: ACCOUNT_BILLING_LOCK_POLICY_ID.to_owned(),
30 rule: rule.to_owned(),
31 human_verification_can_override: false,
32 billing_lock: Some(Box::new(billing_lock)),
33 })),
34 }
35}
36
37pub fn account_billing_lock_from_error_detail(detail: &ErrorDetail) -> Option<&AccountBillingLock> {
40 if detail.reason != ErrorReason::PolicyDenied as i32 {
41 return None;
42 }
43 let error_detail::Context::Policy(policy) = detail.context.as_ref()? else {
44 return None;
45 };
46 if policy.policy_id != ACCOUNT_BILLING_LOCK_POLICY_ID {
47 return None;
48 }
49 policy.billing_lock.as_deref()
50}
51
52pub fn encode_account_billing_lock_error_detail(
54 resource: impl Into<String>,
55 billing_lock: AccountBillingLock,
56) -> Vec<u8> {
57 account_billing_lock_error_detail(resource, billing_lock).encode_to_vec()
58}
59
60pub fn decode_account_billing_lock_error_detail(
63 encoded: &[u8],
64) -> Result<Option<AccountBillingLock>, prost::DecodeError> {
65 let detail = ErrorDetail::decode(encoded)?;
66 Ok(account_billing_lock_from_error_detail(&detail).cloned())
67}
68
69impl CallFailure {
70 pub fn into_stream_failure(
73 mut self,
74 retry: Option<RetryAdvice>,
75 cursor: Option<CursorFailure>,
76 ) -> Self {
77 if retry.is_none()
78 && cursor.is_none()
79 && matches!(
80 self.error.as_ref().and_then(|error| error.context.as_ref()),
81 Some(error_detail::Context::Stream(_))
82 )
83 {
84 return self;
85 }
86 let hint = retry
87 .map(|retry| (ErrorReason::Transient, error_detail::Context::Retry(retry)))
88 .or_else(|| {
89 cursor.map(|cursor| {
90 (
91 ErrorReason::CursorInvalid,
92 error_detail::Context::Cursor(cursor),
93 )
94 })
95 });
96 let nested = match hint {
97 Some((reason, context)) => Some(ErrorDetail {
98 reason: reason as i32,
99 context: Some(context),
100 ..Default::default()
101 }),
102 None => self.error.take(),
103 };
104 self.error = Some(ErrorDetail {
106 reason: nested
107 .as_ref()
108 .map_or(ErrorReason::Unspecified as i32, |error| error.reason),
109 resource: nested
110 .as_ref()
111 .map(|error| error.resource.clone())
112 .unwrap_or_default(),
113 field: nested
114 .as_ref()
115 .map(|error| error.field.clone())
116 .unwrap_or_default(),
117 context: Some(error_detail::Context::Stream(Box::new(StreamFailure {
118 code: self.code,
119 message: self.message.clone(),
120 error: nested.map(Box::new),
121 }))),
122 });
123 self
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use crate::heddle::api::common::{
130 CallFailure, CallFailureCode, CursorFailure, ErrorDetail, ErrorReason, PolicyDenial,
131 RetryAdvice, error_detail,
132 };
133 use prost::Message;
134
135 #[test]
136 fn terminal_failure_preserves_policy_and_is_idempotent_on_the_wire() {
137 let detail = ErrorDetail {
138 reason: ErrorReason::PolicyDenied as i32,
139 resource: "spool:owner/private".into(),
140 field: "sharing_policy".into(),
141 context: Some(error_detail::Context::Policy(PolicyDenial {
142 policy_id: "thread.sharing".into(),
143 rule: "sharing revoked".into(),
144 human_verification_can_override: false,
145 billing_lock: None,
146 })),
147 };
148 let failure = CallFailure {
149 code: CallFailureCode::PermissionDenied as i32,
150 message: "sharing revoked".into(),
151 error: Some(detail.clone()),
152 }
153 .into_stream_failure(None, None);
154 let encoded = failure.encode_to_vec();
155 assert_eq!(
156 encoded,
157 failure
158 .clone()
159 .into_stream_failure(None, None)
160 .encode_to_vec()
161 );
162 let restored = CallFailure::decode(encoded.as_slice()).expect("decode terminal frame");
163 assert_eq!(restored.code, failure.code);
164 assert_eq!(restored.message, failure.message);
165 let outer = restored.error.expect("typed failure");
166 assert_eq!(outer.reason, detail.reason);
167 assert_eq!(outer.resource, detail.resource);
168 assert_eq!(outer.field, detail.field);
169 let Some(error_detail::Context::Stream(stream)) = outer.context else {
170 panic!("terminal failure must carry a stream context");
171 };
172 assert_eq!(stream.code, failure.code);
173 assert_eq!(stream.message, failure.message);
174 assert_eq!(stream.error.as_deref(), Some(&detail));
175 }
176
177 #[test]
178 fn explicit_hints_have_deterministic_precedence() {
179 let base = CallFailure {
180 code: CallFailureCode::Unavailable as i32,
181 message: "resume".into(),
182 error: None,
183 };
184 let cursor = CursorFailure {
185 restart_cursor: "page-42".into(),
186 ..Default::default()
187 };
188 let retry = RetryAdvice {
189 retry_after: Some(prost_types::Duration {
190 seconds: 3,
191 nanos: 0,
192 }),
193 };
194 for (advice, expected) in [
195 (Some(retry), ErrorReason::Transient),
196 (None, ErrorReason::CursorInvalid),
197 ] {
198 let failure = base
199 .clone()
200 .into_stream_failure(advice, Some(cursor.clone()));
201 let outer = failure.error.expect("stream error");
202 assert_eq!(outer.reason, expected as i32);
203 let Some(error_detail::Context::Stream(stream)) = outer.context else {
204 panic!("terminal stream context");
205 };
206 let hint = stream.error.expect("nested hint");
207 match (advice, hint.context) {
208 (Some(expected), Some(error_detail::Context::Retry(actual))) => {
209 assert_eq!(actual, expected)
210 }
211 (None, Some(error_detail::Context::Cursor(actual))) => assert_eq!(actual, cursor),
212 _ => panic!("explicit hint must determine recovery"),
213 }
214 }
215 }
216}