rovenue/
error.rs

1/// Normalized error category — the stable discriminant callers switch on.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum ErrorKind {
4    // network / transport
5    NetworkUnavailable,
6    Timeout,
7    RateLimited,
8    ServerError,
9    // auth / request
10    InvalidApiKey,
11    Forbidden,
12    NotFound,
13    InvalidRequest,
14    Conflict,
15    InvalidArgument,
16    // domain
17    InsufficientCredits,
18    FunnelTokenNotFound,
19    FunnelTokenExpired,
20    FunnelTokenAlreadyClaimed,
21    /// A published funnel asks for a config format this build cannot draw.
22    /// The funnel is not shown at all — a half-rendered onboarding is worse
23    /// than none.
24    FunnelConfigUnsupported,
25    /// A funnel with no pages, or one whose config failed to decode.
26    FunnelInvalid,
27    /// The server cannot resolve this device's subscriber: its merge chain
28    /// is corrupt (backend `SUBSCRIBER_UNRESOLVABLE`, HTTP 409). Terminal and
29    /// not retryable — only the project owner repairing the data clears it.
30    /// Durable queues keep what they hold so it is delivered after repair.
31    SubscriberUnresolvable,
32    // store
33    PurchaseCanceled,
34    ProductNotAvailable,
35    AlreadyOwned,
36    PaymentDeclined,
37    StoreServiceUnavailable,
38    Ineligible,
39    ReceiptInvalid,
40    StoreProblem,
41    // other
42    Storage,
43    Internal,
44}
45
46impl ErrorKind {
47    pub fn is_retryable(&self) -> bool {
48        matches!(
49            self,
50            ErrorKind::NetworkUnavailable
51                | ErrorKind::Timeout
52                | ErrorKind::RateLimited
53                | ErrorKind::ServerError
54                | ErrorKind::StoreServiceUnavailable
55        )
56    }
57
58    /// Default English message used when the backend supplies none.
59    pub fn default_message(&self) -> &'static str {
60        match self {
61            ErrorKind::NetworkUnavailable => "network unavailable",
62            ErrorKind::Timeout => "request timed out",
63            ErrorKind::RateLimited => "rate limited",
64            ErrorKind::ServerError => "server error",
65            ErrorKind::InvalidApiKey => "invalid api key",
66            ErrorKind::Forbidden => "forbidden",
67            ErrorKind::NotFound => "not found",
68            ErrorKind::InvalidRequest => "invalid request",
69            ErrorKind::Conflict => "conflict",
70            ErrorKind::InvalidArgument => "invalid argument",
71            ErrorKind::InsufficientCredits => "insufficient credits",
72            ErrorKind::FunnelTokenNotFound => "funnel token not found",
73            ErrorKind::FunnelTokenExpired => "funnel token expired",
74            ErrorKind::FunnelTokenAlreadyClaimed => "funnel token already claimed",
75            ErrorKind::FunnelConfigUnsupported => "funnel config format is newer than this SDK",
76            ErrorKind::FunnelInvalid => "funnel config is invalid",
77            ErrorKind::SubscriberUnresolvable => "this device's subscriber cannot be resolved",
78            ErrorKind::PurchaseCanceled => "purchase canceled",
79            ErrorKind::ProductNotAvailable => "product not available",
80            ErrorKind::AlreadyOwned => "already owned",
81            ErrorKind::PaymentDeclined => "payment declined",
82            ErrorKind::StoreServiceUnavailable => "store service unavailable",
83            ErrorKind::Ineligible => "ineligible",
84            ErrorKind::ReceiptInvalid => "receipt invalid",
85            ErrorKind::StoreProblem => "store problem",
86            ErrorKind::Storage => "storage error",
87            ErrorKind::Internal => "internal error",
88        }
89    }
90}
91
92/// The single error type the core produces and exports across FFI.
93#[derive(Debug, Clone)]
94pub struct RovenueError {
95    pub kind: ErrorKind,
96    pub message: String,
97    pub server_code: Option<String>,
98    pub http_status: Option<u16>,
99    pub retryable: bool,
100}
101
102impl RovenueError {
103    /// Construct from a kind alone (network/store/internal paths).
104    pub fn kind(kind: ErrorKind) -> Self {
105        Self {
106            kind,
107            message: kind.default_message().to_string(),
108            server_code: None,
109            http_status: None,
110            retryable: kind.is_retryable(),
111        }
112    }
113    /// Construct from an HTTP error, preserving the backend code/message.
114    pub fn http(
115        kind: ErrorKind,
116        status: u16,
117        server_code: Option<String>,
118        message: String,
119    ) -> Self {
120        let message = if message.is_empty() {
121            kind.default_message().to_string()
122        } else {
123            message
124        };
125        Self {
126            kind,
127            message,
128            server_code,
129            http_status: Some(status),
130            retryable: kind.is_retryable(),
131        }
132    }
133
134    /// Whether this error is a genuine envelope rejection a durable queue
135    /// should poison-delete rather than retain.
136    ///
137    /// Narrow on purpose: only 400 (bad request) and 422 (unprocessable
138    /// entity) mean the payload itself is malformed and the server will
139    /// never accept it, so retaining it would wedge everything queued
140    /// behind it forever. Every other 4xx — 401/403 (auth blip or key
141    /// rotation), 408 (timeout), 404, 409 (e.g. `SubscriberUnresolvable`,
142    /// which holds the queue until the owner repairs the merge chain),
143    /// etc. — is transient from the queue's point of view and must be
144    /// retained: a bad or rotated API key must not permanently drop queued
145    /// events, only pause delivery until the key is fixed. Retryable kinds (5xx/network/429) are never
146    /// poison either way — the `!self.retryable` guard is redundant with
147    /// the status check today (400/422 both map to non-retryable
148    /// `InvalidRequest`) but keeps this correct if that mapping ever
149    /// changes.
150    ///
151    /// Shared by both durable-queue drain sites: `events::queue` and
152    /// `sessions::dispatcher`. (`attributes::dispatcher` drops every
153    /// non-retryable 4xx except `SubscriberUnresolvable`.)
154    pub fn is_poison(&self) -> bool {
155        !self.retryable && matches!(self.http_status, Some(400) | Some(422))
156    }
157}
158
159impl RovenueError {
160    // ── Shim constructors preserving old call-site API ──────────────────────
161    // PascalCase names are intentional: they mirror the old fieldless-variant API
162    // so hundreds of call sites compile unchanged while Task 2+ migrates them
163    // incrementally. `#[allow(non_snake_case)]` is scoped to this impl block only.
164    #[allow(non_snake_case)]
165    pub fn Internal() -> Self {
166        Self::kind(ErrorKind::Internal)
167    }
168    #[allow(non_snake_case)]
169    pub fn NetworkUnavailable() -> Self {
170        Self::kind(ErrorKind::NetworkUnavailable)
171    }
172    #[allow(non_snake_case)]
173    pub fn ServerError() -> Self {
174        Self::kind(ErrorKind::ServerError)
175    }
176    #[allow(non_snake_case)]
177    pub fn RateLimited() -> Self {
178        Self::kind(ErrorKind::RateLimited)
179    }
180    #[allow(non_snake_case)]
181    pub fn InvalidApiKey() -> Self {
182        Self::kind(ErrorKind::InvalidApiKey)
183    }
184    #[allow(non_snake_case)]
185    pub fn Timeout() -> Self {
186        Self::kind(ErrorKind::Timeout)
187    }
188    #[allow(non_snake_case)]
189    pub fn InsufficientCredits() -> Self {
190        Self::kind(ErrorKind::InsufficientCredits)
191    }
192    #[allow(non_snake_case)]
193    pub fn Storage() -> Self {
194        Self::kind(ErrorKind::Storage)
195    }
196    #[allow(non_snake_case)]
197    pub fn InvalidArgument() -> Self {
198        Self::kind(ErrorKind::InvalidArgument)
199    }
200    #[allow(non_snake_case)]
201    pub fn FunnelConfigUnsupported() -> Self {
202        Self::kind(ErrorKind::FunnelConfigUnsupported)
203    }
204    #[allow(non_snake_case)]
205    pub fn FunnelInvalid() -> Self {
206        Self::kind(ErrorKind::FunnelInvalid)
207    }
208    #[allow(non_snake_case)]
209    pub fn FunnelTokenNotFound() -> Self {
210        Self::kind(ErrorKind::FunnelTokenNotFound)
211    }
212    #[allow(non_snake_case)]
213    pub fn FunnelTokenExpired() -> Self {
214        Self::kind(ErrorKind::FunnelTokenExpired)
215    }
216    #[allow(non_snake_case)]
217    pub fn FunnelTokenAlreadyClaimed() -> Self {
218        Self::kind(ErrorKind::FunnelTokenAlreadyClaimed)
219    }
220}
221
222impl std::fmt::Display for RovenueError {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        write!(f, "{}", self.message)
225    }
226}
227
228impl std::error::Error for RovenueError {}
229
230pub type RovenueResult<T> = std::result::Result<T, RovenueError>;
231
232// ── FFI projection ──────────────────────────────────────────────────────────
233//
234// uniffi 0.25 (UDL mode) requires the thrown error to be a Rust *enum* whose
235// variant carries the fields declared in `[Error] interface RovenueErrorFfi`.
236// We keep the ergonomic `RovenueError` struct for all internal Rust use (field
237// access `e.kind`, `e.http_status`, …) and project it onto this single-variant
238// rich enum at the FFI boundary. uniffi's generated scaffolding appends
239// `.map_err(Into::into)` to every `[Throws]` method in UDL mode, so the
240// `From<RovenueError>` impl below is the only wiring needed — the `RovenueCore`
241// methods continue to return `RovenueResult<T>` unchanged.
242//
243// The generated Swift `RovenueErrorFfi` / Kotlin `RovenueErrorFfiException`
244// surface the `.Generic` case carrying kind/message/serverCode/httpStatus/
245// retryable, which the façade tasks (5–7) consume.
246#[derive(Debug, Clone)]
247pub enum RovenueErrorFfi {
248    Generic {
249        kind: ErrorKind,
250        /// Renamed from `message` to `detail` to avoid collision with
251        /// Kotlin `Exception.message` / Swift `LocalizedError.errorDescription`
252        /// in the uniffi-generated façade types.
253        detail: String,
254        server_code: Option<String>,
255        http_status: Option<u16>,
256        retryable: bool,
257    },
258}
259
260impl From<RovenueError> for RovenueErrorFfi {
261    fn from(e: RovenueError) -> Self {
262        RovenueErrorFfi::Generic {
263            kind: e.kind,
264            detail: e.message,
265            server_code: e.server_code,
266            http_status: e.http_status,
267            retryable: e.retryable,
268        }
269    }
270}
271
272impl std::fmt::Display for RovenueErrorFfi {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        let RovenueErrorFfi::Generic { detail, .. } = self;
275        write!(f, "{detail}")
276    }
277}
278
279impl std::error::Error for RovenueErrorFfi {}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn retryable_kinds_are_classified() {
287        for k in [
288            ErrorKind::NetworkUnavailable,
289            ErrorKind::Timeout,
290            ErrorKind::RateLimited,
291            ErrorKind::ServerError,
292            ErrorKind::StoreServiceUnavailable,
293        ] {
294            assert!(k.is_retryable(), "{k:?} should be retryable");
295        }
296        for k in [
297            ErrorKind::InvalidApiKey,
298            ErrorKind::Forbidden,
299            ErrorKind::NotFound,
300            ErrorKind::InvalidRequest,
301            ErrorKind::InsufficientCredits,
302            ErrorKind::Internal,
303        ] {
304            assert!(!k.is_retryable(), "{k:?} should NOT be retryable");
305        }
306    }
307
308    #[test]
309    fn http_constructor_carries_fields() {
310        let e = RovenueError::http(
311            ErrorKind::Forbidden,
312            403,
313            Some("FORBIDDEN".into()),
314            "no access".into(),
315        );
316        assert_eq!(e.kind, ErrorKind::Forbidden);
317        assert_eq!(e.http_status, Some(403));
318        assert_eq!(e.server_code.as_deref(), Some("FORBIDDEN"));
319        assert_eq!(e.message, "no access");
320        assert!(!e.retryable);
321    }
322
323    #[test]
324    fn kind_constructor_uses_default_message_and_no_http() {
325        let e = RovenueError::kind(ErrorKind::Timeout);
326        assert_eq!(e.kind, ErrorKind::Timeout);
327        assert_eq!(e.http_status, None);
328        assert_eq!(e.server_code, None);
329        assert!(e.retryable);
330        assert!(!e.message.is_empty());
331    }
332}