Skip to main content

onesignal_rust_api/
helpers.rs

1//! Helpers for common OneSignal API usage patterns.
2
3use std::time::Duration;
4
5use crate::apis::configuration;
6use crate::apis::default_api::CreateNotificationError;
7use crate::apis::{Error, ResponseContent};
8use crate::models;
9
10const RETRYABLE_STATUSES: [u16; 2] = [429, 503];
11const MIN_BASE_DELAY: Duration = Duration::from_secs(1);
12const MAX_BASE_DELAY: Duration = Duration::from_secs(60);
13
14/// Options for [`create_notification_with_retry`].
15#[derive(Debug, Clone)]
16pub struct CreateNotificationWithRetryOptions {
17    /// Retries after the initial attempt. Default 3.
18    pub max_retries: u32,
19    /// Backoff base used when the response carries no `Retry-After` header.
20    /// Clamped to [1s, 60s]. Default 1s.
21    pub base_delay: Duration,
22}
23
24impl Default for CreateNotificationWithRetryOptions {
25    fn default() -> Self {
26        Self {
27            max_retries: 3,
28            base_delay: Duration::from_secs(1),
29        }
30    }
31}
32
33/// Result of [`create_notification_with_retry`]: the create response plus
34/// whether the server replayed a previously completed request
35/// (`Idempotent-Replayed` response header).
36#[derive(Debug, Clone)]
37pub struct CreateNotificationWithRetryResult {
38    pub response: models::CreateNotificationSuccessResponse,
39    pub was_replayed: bool,
40}
41
42enum AttemptOutcome {
43    Fatal(Error<CreateNotificationError>),
44    Retryable(Error<CreateNotificationError>, Option<Duration>),
45}
46
47/// Create a notification with safe, idempotent retries.
48///
49/// Ensures `notification.idempotency_key` is set (generating a UUIDv4 when
50/// absent) so the server can deduplicate, then posts to `/notifications`.
51/// Transient failures (HTTP 429, HTTP 503, or connect/timeout errors) are
52/// retried with the SAME idempotency key, honoring the `Retry-After` response
53/// header when present and falling back to exponential backoff
54/// (`base_delay * 2^attempt`) otherwise. Other errors are returned
55/// immediately. An existing idempotency key is respected, never overwritten.
56///
57/// NOTE: the request logic below mirrors `apis::default_api::create_notification`
58/// (which does not expose response headers); keep the two in sync when the
59/// generated operation changes.
60pub async fn create_notification_with_retry(
61    configuration: &configuration::Configuration,
62    notification: &mut models::Notification,
63    options: Option<CreateNotificationWithRetryOptions>,
64) -> Result<CreateNotificationWithRetryResult, Error<CreateNotificationError>> {
65    let opts = options.unwrap_or_default();
66    // Clamp the backoff base so a stray value can neither hammer the API (too
67    // small) nor stall the caller for an unbounded stretch (too large).
68    let base_delay = opts.base_delay.clamp(MIN_BASE_DELAY, MAX_BASE_DELAY);
69
70    if notification
71        .idempotency_key
72        .as_deref()
73        .map_or(true, |key| key.is_empty())
74    {
75        notification.idempotency_key = Some(uuid::Uuid::new_v4().to_string());
76    }
77
78    let mut attempt: u32 = 0;
79    loop {
80        match send_once(configuration, notification).await {
81            Ok(result) => return Ok(result),
82            Err(AttemptOutcome::Fatal(error)) => return Err(error),
83            Err(AttemptOutcome::Retryable(error, retry_after)) => {
84                if attempt >= opts.max_retries {
85                    return Err(error);
86                }
87                let delay =
88                    retry_after.unwrap_or_else(|| base_delay * 2u32.saturating_pow(attempt));
89                if !delay.is_zero() {
90                    tokio::time::sleep(delay).await;
91                }
92                attempt += 1;
93            }
94        }
95    }
96}
97
98async fn send_once(
99    configuration: &configuration::Configuration,
100    notification: &models::Notification,
101) -> Result<CreateNotificationWithRetryResult, AttemptOutcome> {
102    let client = &configuration.client;
103
104    let uri_str = format!("{}/notifications", configuration.base_path);
105    let mut req_builder = client.request(reqwest::Method::POST, uri_str.as_str());
106
107    if let Some(ref user_agent) = configuration.user_agent {
108        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
109    }
110
111    // Adds a telemetry header
112    req_builder = req_builder.header(
113        "OS-Usage-Data",
114        concat!(
115            "kind=sdk, sdk-name=onesignal-rust, version=",
116            env!("CARGO_PKG_VERSION")
117        ),
118    );
119
120    if let Some(ref token) = configuration.rest_api_key_token {
121        req_builder = req_builder.header("Authorization", format!("Key {}", token.to_owned()));
122    }
123    req_builder = req_builder.json(notification);
124
125    let req = req_builder
126        .build()
127        .map_err(|e| AttemptOutcome::Fatal(Error::Reqwest(e)))?;
128    let resp = match client.execute(req).await {
129        Ok(resp) => resp,
130        Err(e) => {
131            return Err(if e.is_timeout() || e.is_connect() {
132                AttemptOutcome::Retryable(Error::Reqwest(e), None)
133            } else {
134                AttemptOutcome::Fatal(Error::Reqwest(e))
135            });
136        }
137    };
138
139    let status = resp.status();
140    let was_replayed = header_value(&resp, "idempotent-replayed")
141        .map_or(false, |value| value.trim().eq_ignore_ascii_case("true"));
142    let retry_after = header_value(&resp, "retry-after")
143        .and_then(|value| value.trim().parse::<u64>().ok())
144        .map(Duration::from_secs);
145
146    let content = resp
147        .text()
148        .await
149        .map_err(|e| AttemptOutcome::Fatal(Error::Reqwest(e)))?;
150
151    if !status.is_client_error() && !status.is_server_error() {
152        let response = serde_json::from_str(&content)
153            .map_err(|e| AttemptOutcome::Fatal(Error::Serde(e)))?;
154        Ok(CreateNotificationWithRetryResult {
155            response,
156            was_replayed,
157        })
158    } else {
159        let entity: Option<CreateNotificationError> = serde_json::from_str(&content).ok();
160        let error = Error::ResponseError(ResponseContent {
161            status,
162            content,
163            entity,
164        });
165        Err(if RETRYABLE_STATUSES.contains(&status.as_u16()) {
166            AttemptOutcome::Retryable(error, retry_after)
167        } else {
168            AttemptOutcome::Fatal(error)
169        })
170    }
171}
172
173/// The branch of a POST /notifications 200 response where a notification was
174/// created (`id` is a non-empty string). Shares the
175/// [`models::CreateNotificationSuccessResponse`] shape; see
176/// [`models::CreateNotificationSuccessResponse::as_sent`].
177pub type MessageSent = models::CreateNotificationSuccessResponse;
178
179/// The branch of a POST /notifications 200 response where NO notification was
180/// created (`id` is absent or empty); `errors` carries the reason. Shares the
181/// [`models::CreateNotificationSuccessResponse`] shape; see
182/// [`models::CreateNotificationSuccessResponse::as_not_sent`].
183pub type MessageNotSent = models::CreateNotificationSuccessResponse;
184
185impl models::CreateNotificationSuccessResponse {
186    /// Whether this is the [`MessageSent`] branch — a notification was created
187    /// (`id` is present and non-empty). Prefer this over inspecting `id`
188    /// directly.
189    pub fn is_message_sent(&self) -> bool {
190        self.id.as_deref().map_or(false, |id| !id.is_empty())
191    }
192
193    /// Whether this is the [`MessageNotSent`] branch — no notification was
194    /// created (`id` absent or empty); inspect `errors` for why.
195    pub fn is_message_not_sent(&self) -> bool {
196        !self.is_message_sent()
197    }
198
199    /// Returns `Some(self)` viewed as a [`MessageSent`] when a notification was
200    /// created, otherwise `None`.
201    pub fn as_sent(&self) -> Option<&MessageSent> {
202        if self.is_message_sent() {
203            Some(self)
204        } else {
205            None
206        }
207    }
208
209    /// Returns `Some(self)` viewed as a [`MessageNotSent`] when no notification
210    /// was created, otherwise `None`.
211    pub fn as_not_sent(&self) -> Option<&MessageNotSent> {
212        if self.is_message_not_sent() {
213            Some(self)
214        } else {
215            None
216        }
217    }
218}
219
220fn header_value(resp: &reqwest::Response, name: &str) -> Option<String> {
221    resp.headers()
222        .get(name)
223        .and_then(|value| value.to_str().ok())
224        .map(|value| value.to_owned())
225}