Skip to main content

heddle_api/
failure.rs

1//! Terminal stream failures belong to the wire contract, independent of a host.
2
3use crate::heddle::api::common::{
4    AccountBillingLock, AccountBillingLockReason, CallFailure, CursorFailure, ErrorDetail,
5    ErrorReason, PolicyDenial, RetryAdvice, StreamFailure, error_detail,
6};
7use prost::Message;
8
9/// Stable policy identifier for the account billing lock.
10pub const ACCOUNT_BILLING_LOCK_POLICY_ID: &str = "account_locked_billing";
11
12/// Builds the single structured error detail used for an account billing-lock
13/// denial. Hosts attach this detail to a `FAILED_PRECONDITION` failure.
14pub 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
37/// Returns the typed lock from a policy denial, rejecting details with a
38/// different reason, policy identifier, or context arm.
39pub 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
52/// Encodes a complete account billing-lock `ErrorDetail` for transport.
53pub 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
60/// Decodes a transported `ErrorDetail` and extracts its account billing lock.
61/// A valid non-billing detail returns `Ok(None)`.
62pub 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    /// Preserve the original failure inside the terminal stream context.
71    /// Explicit retry advice takes precedence over a cursor, then existing detail.
72    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        // The protobuf error graph is recursive; its generated fields require Box.
105        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}