max / makenotwork
9 files changed,
+170 insertions,
-54 deletions
| @@ -11,7 +11,7 @@ use crate::{ | |||
| 11 | 11 | }; | |
| 12 | 12 | ||
| 13 | 13 | use super::{Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS}; | |
| 14 | - | use super::helpers::{check_response, jwt_exp}; | |
| 14 | + | use super::helpers::{check_response, jwt_exp, Idempotency}; | |
| 15 | 15 | ||
| 16 | 16 | impl SyncKitClient { | |
| 17 | 17 | /// Authenticate with the MNW server. Returns (user_id, app_id). | |
| @@ -41,7 +41,7 @@ impl SyncKitClient { | |||
| 41 | 41 | })?); | |
| 42 | 42 | ||
| 43 | 43 | let resp = self | |
| 44 | - | .retry_request(|| { | |
| 44 | + | .retry_request(Idempotency::Safe, || { | |
| 45 | 45 | let req = self | |
| 46 | 46 | .http | |
| 47 | 47 | .post(&self.endpoints.auth) |
| @@ -9,7 +9,7 @@ use crate::{ | |||
| 9 | 9 | }; | |
| 10 | 10 | ||
| 11 | 11 | use super::SyncKitClient; | |
| 12 | - | use super::helpers::check_response; | |
| 12 | + | use super::helpers::{check_response, Idempotency}; | |
| 13 | 13 | ||
| 14 | 14 | /// Upper bound on a single decrypted blob held in memory, guarding against a | |
| 15 | 15 | /// hostile server returning an absurdly large body that would OOM the client. | |
| @@ -38,7 +38,7 @@ impl SyncKitClient { | |||
| 38 | 38 | size_bytes, | |
| 39 | 39 | })?); | |
| 40 | 40 | ||
| 41 | - | self.retry_request_json(|| { | |
| 41 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 42 | 42 | let req = self | |
| 43 | 43 | .http | |
| 44 | 44 | .post(&self.endpoints.blobs_upload) | |
| @@ -62,7 +62,7 @@ impl SyncKitClient { | |||
| 62 | 62 | let encrypted = | |
| 63 | 63 | Bytes::from(crypto::encrypt_bytes_aad(&data, &master_key, hash.as_bytes())?); | |
| 64 | 64 | ||
| 65 | - | self.retry_request(|| { | |
| 65 | + | self.retry_request(Idempotency::Safe, || { | |
| 66 | 66 | let req = self | |
| 67 | 67 | .http | |
| 68 | 68 | .put(presigned_url) | |
| @@ -91,7 +91,7 @@ impl SyncKitClient { | |||
| 91 | 91 | size_bytes, | |
| 92 | 92 | })?); | |
| 93 | 93 | ||
| 94 | - | self.retry_request(|| { | |
| 94 | + | self.retry_request(Idempotency::Safe, || { | |
| 95 | 95 | let req = self | |
| 96 | 96 | .http | |
| 97 | 97 | .post(&self.endpoints.blobs_confirm) | |
| @@ -114,7 +114,7 @@ impl SyncKitClient { | |||
| 114 | 114 | })?); | |
| 115 | 115 | ||
| 116 | 116 | let download: BlobDownloadUrlResponse = self | |
| 117 | - | .retry_request_json(|| { | |
| 117 | + | .retry_request_json(Idempotency::Safe, || { | |
| 118 | 118 | let req = self | |
| 119 | 119 | .http | |
| 120 | 120 | .post(&self.endpoints.blobs_download) | |
| @@ -141,7 +141,7 @@ impl SyncKitClient { | |||
| 141 | 141 | #[instrument(skip(self, presigned_url))] | |
| 142 | 142 | pub async fn blob_download(&self, expected_hash: &str, presigned_url: &str) -> Result<Vec<u8>> { | |
| 143 | 143 | let resp = self | |
| 144 | - | .retry_request(|| { | |
| 144 | + | .retry_request(Idempotency::Safe, || { | |
| 145 | 145 | let req = self.http.get(presigned_url); | |
| 146 | 146 | async move { check_response(req.send().await?).await } | |
| 147 | 147 | }) |
| @@ -10,7 +10,7 @@ use crate::{ | |||
| 10 | 10 | }; | |
| 11 | 11 | ||
| 12 | 12 | use super::SyncKitClient; | |
| 13 | - | use super::helpers::check_response; | |
| 13 | + | use super::helpers::{check_response, Idempotency}; | |
| 14 | 14 | ||
| 15 | 15 | impl SyncKitClient { | |
| 16 | 16 | /// Check if the server has an encrypted master key for this user. | |
| @@ -19,7 +19,7 @@ impl SyncKitClient { | |||
| 19 | 19 | let (url, token) = self.key_url_and_token()?; | |
| 20 | 20 | ||
| 21 | 21 | let result = self | |
| 22 | - | .retry_request(|| { | |
| 22 | + | .retry_request(Idempotency::Safe, || { | |
| 23 | 23 | let req = self.http.get(url).bearer_auth(&token); | |
| 24 | 24 | async move { | |
| 25 | 25 | let resp = req.send().await?; | |
| @@ -143,7 +143,7 @@ impl SyncKitClient { | |||
| 143 | 143 | expected_version, | |
| 144 | 144 | })?); | |
| 145 | 145 | ||
| 146 | - | self.retry_request(|| { | |
| 146 | + | self.retry_request(Idempotency::Safe, || { | |
| 147 | 147 | let req = self | |
| 148 | 148 | .http | |
| 149 | 149 | .put(url) | |
| @@ -169,7 +169,7 @@ impl SyncKitClient { | |||
| 169 | 169 | pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> { | |
| 170 | 170 | let (url, token) = self.key_url_and_token()?; | |
| 171 | 171 | ||
| 172 | - | self.retry_request_json(|| { | |
| 172 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 173 | 173 | let req = self.http.get(url).bearer_auth(&token); | |
| 174 | 174 | async move { check_response(req.send().await?).await } | |
| 175 | 175 | }) |
| @@ -8,17 +8,42 @@ use crate::{ | |||
| 8 | 8 | ||
| 9 | 9 | use super::{BASE_DELAY, MAX_RETRIES, SyncKitClient}; | |
| 10 | 10 | ||
| 11 | + | /// Proof, supplied at the call site, that retrying an operation is sound. | |
| 12 | + | /// | |
| 13 | + | /// Retry replays the request, so it is only safe when the server treats the | |
| 14 | + | /// operation idempotently. Requiring this argument makes that judgement an | |
| 15 | + | /// explicit, reviewable decision: a new endpoint cannot be fed to a retry | |
| 16 | + | /// helper without naming why replay is harmless here. There is deliberately no | |
| 17 | + | /// `Unsafe`/`Never` variant — an operation that cannot tolerate replay simply | |
| 18 | + | /// must not call a retry helper. | |
| 19 | + | #[derive(Clone, Copy, Debug)] | |
| 20 | + | pub(super) enum Idempotency { | |
| 21 | + | /// Read-only, or a write whose replay yields the same end state (a `GET`, | |
| 22 | + | /// a `PUT`/`DELETE` of a fixed resource, or an upsert keyed on stable input). | |
| 23 | + | Safe, | |
| 24 | + | /// Carries a client-generated idempotency key (e.g. push's `batch_id`) so | |
| 25 | + | /// the server collapses duplicate deliveries into one effect. | |
| 26 | + | Keyed, | |
| 27 | + | } | |
| 28 | + | ||
| 11 | 29 | impl SyncKitClient { | |
| 12 | 30 | /// Retry an async HTTP operation with exponential backoff. | |
| 13 | 31 | /// | |
| 14 | 32 | /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`] | |
| 15 | 33 | /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail. | |
| 16 | 34 | /// Client errors (4xx except 429) are considered permanent and returned immediately. | |
| 17 | - | pub(super) async fn retry_request<F, Fut>(&self, mut operation: F) -> Result<reqwest::Response> | |
| 35 | + | /// | |
| 36 | + | /// `idempotency` is the caller's proof that replay is safe — see [`Idempotency`]. | |
| 37 | + | pub(super) async fn retry_request<F, Fut>( | |
| 38 | + | &self, | |
| 39 | + | idempotency: Idempotency, | |
| 40 | + | mut operation: F, | |
| 41 | + | ) -> Result<reqwest::Response> | |
| 18 | 42 | where | |
| 19 | 43 | F: FnMut() -> Fut, | |
| 20 | 44 | Fut: std::future::Future<Output = Result<reqwest::Response>>, | |
| 21 | 45 | { | |
| 46 | + | let _ = idempotency; | |
| 22 | 47 | let mut last_err = None; | |
| 23 | 48 | ||
| 24 | 49 | for attempt in 0..=MAX_RETRIES { | |
| @@ -53,12 +78,19 @@ impl SyncKitClient { | |||
| 53 | 78 | /// the retry loop. This ensures a transient body-read failure (truncated | |
| 54 | 79 | /// response, connection reset mid-body) is retried rather than surfacing | |
| 55 | 80 | /// as a permanent error after the server already committed the operation. | |
| 56 | - | pub(super) async fn retry_request_json<F, Fut, T>(&self, mut operation: F) -> Result<T> | |
| 81 | + | /// | |
| 82 | + | /// `idempotency` is the caller's proof that replay is safe — see [`Idempotency`]. | |
| 83 | + | pub(super) async fn retry_request_json<F, Fut, T>( | |
| 84 | + | &self, | |
| 85 | + | idempotency: Idempotency, | |
| 86 | + | mut operation: F, | |
| 87 | + | ) -> Result<T> | |
| 57 | 88 | where | |
| 58 | 89 | F: FnMut() -> Fut, | |
| 59 | 90 | Fut: std::future::Future<Output = Result<reqwest::Response>>, | |
| 60 | 91 | T: serde::de::DeserializeOwned, | |
| 61 | 92 | { | |
| 93 | + | let _ = idempotency; | |
| 62 | 94 | let mut last_err = None; | |
| 63 | 95 | ||
| 64 | 96 | for attempt in 0..=MAX_RETRIES { |
| @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; | |||
| 19 | 19 | use tracing::instrument; | |
| 20 | 20 | use uuid::Uuid; | |
| 21 | 21 | ||
| 22 | - | use super::helpers::check_response; | |
| 22 | + | use super::helpers::{check_response, Idempotency}; | |
| 23 | 23 | use super::SyncKitClient; | |
| 24 | 24 | use crate::error::Result; | |
| 25 | 25 | ||
| @@ -98,7 +98,7 @@ impl SyncKitClient { | |||
| 98 | 98 | signature, | |
| 99 | 99 | })?); | |
| 100 | 100 | ||
| 101 | - | self.retry_request_json(|| { | |
| 101 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 102 | 102 | let req = self | |
| 103 | 103 | .http | |
| 104 | 104 | .post(&url) | |
| @@ -131,7 +131,7 @@ impl SyncKitClient { | |||
| 131 | 131 | file_size, | |
| 132 | 132 | })?); | |
| 133 | 133 | ||
| 134 | - | self.retry_request_json(|| { | |
| 134 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 135 | 135 | let req = self | |
| 136 | 136 | .http | |
| 137 | 137 | .post(&url) | |
| @@ -149,7 +149,7 @@ impl SyncKitClient { | |||
| 149 | 149 | #[instrument(skip(self, presigned_url, data))] | |
| 150 | 150 | pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> { | |
| 151 | 151 | let data = Bytes::from(data); | |
| 152 | - | self.retry_request(|| { | |
| 152 | + | self.retry_request(Idempotency::Safe, || { | |
| 153 | 153 | let req = self | |
| 154 | 154 | .http | |
| 155 | 155 | .put(presigned_url) | |
| @@ -178,7 +178,7 @@ impl SyncKitClient { | |||
| 178 | 178 | let url = format!("{base}/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}"); | |
| 179 | 179 | ||
| 180 | 180 | let resp = self | |
| 181 | - | .retry_request(|| { | |
| 181 | + | .retry_request(Idempotency::Safe, || { | |
| 182 | 182 | let req = self.http.get(&url); | |
| 183 | 183 | async move { check_response(req.send().await?).await } | |
| 184 | 184 | }) |
| @@ -10,7 +10,7 @@ use crate::{ | |||
| 10 | 10 | }; | |
| 11 | 11 | ||
| 12 | 12 | use super::SyncKitClient; | |
| 13 | - | use super::helpers::check_response; | |
| 13 | + | use super::helpers::{check_response, Idempotency}; | |
| 14 | 14 | ||
| 15 | 15 | impl SyncKitClient { | |
| 16 | 16 | /// Rotate the master encryption key. | |
| @@ -200,7 +200,7 @@ impl SyncKitClient { | |||
| 200 | 200 | expected_key_version, | |
| 201 | 201 | })?); | |
| 202 | 202 | ||
| 203 | - | self.retry_request_json(|| { | |
| 203 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 204 | 204 | let req = self | |
| 205 | 205 | .http | |
| 206 | 206 | .post(&url) | |
| @@ -225,7 +225,7 @@ impl SyncKitClient { | |||
| 225 | 225 | after_seq, | |
| 226 | 226 | })?); | |
| 227 | 227 | ||
| 228 | - | self.retry_request_json(|| { | |
| 228 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 229 | 229 | let req = self | |
| 230 | 230 | .http | |
| 231 | 231 | .post(&url) | |
| @@ -250,7 +250,7 @@ impl SyncKitClient { | |||
| 250 | 250 | entries, | |
| 251 | 251 | })?); | |
| 252 | 252 | ||
| 253 | - | self.retry_request_json(|| { | |
| 253 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 254 | 254 | let req = self | |
| 255 | 255 | .http | |
| 256 | 256 | .post(&url) | |
| @@ -273,7 +273,7 @@ impl SyncKitClient { | |||
| 273 | 273 | rotation_id, | |
| 274 | 274 | })?); | |
| 275 | 275 | ||
| 276 | - | self.retry_request(|| { | |
| 276 | + | self.retry_request(Idempotency::Safe, || { | |
| 277 | 277 | let req = self | |
| 278 | 278 | .http | |
| 279 | 279 | .post(&url) |
| @@ -8,11 +8,51 @@ | |||
| 8 | 8 | //! once the user clicks subscribe. | |
| 9 | 9 | ||
| 10 | 10 | use bytes::Bytes; | |
| 11 | - | use serde::{Deserialize, Serialize}; | |
| 11 | + | use serde::{Deserialize, Deserializer, Serialize, Serializer}; | |
| 12 | 12 | ||
| 13 | 13 | use crate::error::Result; | |
| 14 | 14 | use super::SyncKitClient; | |
| 15 | - | use super::helpers::check_response; | |
| 15 | + | use super::helpers::{check_response, Idempotency}; | |
| 16 | + | ||
| 17 | + | /// A monetary amount in whole cents. | |
| 18 | + | /// | |
| 19 | + | /// All pricing arithmetic flows through this newtype so the saturating | |
| 20 | + | /// discipline lives in one place rather than being re-derived at each call | |
| 21 | + | /// site: the inputs are server-supplied and a hostile or buggy value must | |
| 22 | + | /// never panic (debug overflow) or wrap to a negative price (release). It is | |
| 23 | + | /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64` | |
| 24 | + | /// it replaces. | |
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] | |
| 26 | + | #[serde(transparent)] | |
| 27 | + | pub struct Cents(pub i64); | |
| 28 | + | ||
| 29 | + | impl Cents { | |
| 30 | + | /// The underlying whole-cent count. | |
| 31 | + | pub const fn get(self) -> i64 { | |
| 32 | + | self.0 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing. | |
| 36 | + | pub fn saturating_add(self, other: Cents) -> Cents { | |
| 37 | + | Cents(self.0.saturating_add(other.0)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Scale by an integer factor (e.g. a months multiplier), saturating. | |
| 41 | + | pub fn saturating_mul(self, factor: i64) -> Cents { | |
| 42 | + | Cents(self.0.saturating_mul(factor)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// The larger of two amounts (e.g. applying a price floor). | |
| 46 | + | pub fn max(self, other: Cents) -> Cents { | |
| 47 | + | Cents(self.0.max(other.0)) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl std::fmt::Display for Cents { | |
| 52 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 53 | + | write!(f, "{}", self.0) | |
| 54 | + | } | |
| 55 | + | } | |
| 16 | 56 | ||
| 17 | 57 | /// Subscription status as returned by the server. | |
| 18 | 58 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| @@ -20,10 +60,10 @@ use super::helpers::check_response; | |||
| 20 | 60 | pub struct SubscriptionStatus { | |
| 21 | 61 | /// Whether the user has an active sync subscription for this app. | |
| 22 | 62 | pub active: bool, | |
| 23 | - | /// Billing interval ("monthly" or "annual"). `None` if no subscription. | |
| 63 | + | /// Billing interval. `None` if no subscription. | |
| 24 | 64 | /// Kept under the legacy field name `tier` on the wire for SDK compat. | |
| 25 | 65 | #[serde(rename = "tier")] | |
| 26 | - | pub interval: Option<String>, | |
| 66 | + | pub interval: Option<BillingInterval>, | |
| 27 | 67 | /// Subscription status string (e.g. "active", "past_due", "canceled"). | |
| 28 | 68 | pub status: Option<String>, | |
| 29 | 69 | /// Current blob storage cap, in bytes. | |
| @@ -45,8 +85,8 @@ pub struct SubscriptionStatus { | |||
| 45 | 85 | #[non_exhaustive] | |
| 46 | 86 | pub struct AppPricing { | |
| 47 | 87 | pub app_name: String, | |
| 48 | - | /// Minimum charge in cents (applies to both monthly and annual). | |
| 49 | - | pub min_charge_cents: i64, | |
| 88 | + | /// Minimum charge (applies to both monthly and annual). | |
| 89 | + | pub min_charge_cents: Cents, | |
| 50 | 90 | /// Storage rate per GiB per month, in tenths of a cent. Convert with | |
| 51 | 91 | /// `(gib * per_gb_tenths + 9) / 10` to get cents. | |
| 52 | 92 | pub per_gb_tenths_of_cent_per_month: i64, | |
| @@ -65,11 +105,15 @@ impl AppPricing { | |||
| 65 | 105 | /// a hostile or buggy server returning huge rates must not panic the client | |
| 66 | 106 | /// (debug overflow) or wrap to a negative price (release). The server is | |
| 67 | 107 | /// authoritative at checkout, so this is an advisory preview regardless. | |
| 68 | - | pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> i64 { | |
| 108 | + | pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents { | |
| 69 | 109 | let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes); | |
| 70 | 110 | let gib = cap_bytes_to_gib_ceil(cap_bytes); | |
| 71 | - | let storage_monthly = | |
| 72 | - | gib.saturating_mul(self.per_gb_tenths_of_cent_per_month).saturating_add(9) / 10; | |
| 111 | + | // The storage rate is tenths-of-a-cent per GiB; convert to whole cents, | |
| 112 | + | // rounding up. Intermediate stays i64 (a rate, not money) until the | |
| 113 | + | // result becomes a `Cents` amount. | |
| 114 | + | let storage_monthly = Cents( | |
| 115 | + | gib.saturating_mul(self.per_gb_tenths_of_cent_per_month).saturating_add(9) / 10, | |
| 116 | + | ); | |
| 73 | 117 | let monthly = storage_monthly.max(self.min_charge_cents); | |
| 74 | 118 | match interval { | |
| 75 | 119 | BillingInterval::Monthly => monthly, | |
| @@ -84,12 +128,30 @@ fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 { | |||
| 84 | 128 | } | |
| 85 | 129 | ||
| 86 | 130 | /// Billing interval for SyncKit subscriptions. | |
| 131 | + | /// | |
| 132 | + | /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization | |
| 133 | + | /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps | |
| 134 | + | /// to `Monthly` rather than erroring, so a future server-side interval cannot | |
| 135 | + | /// break an older client's status parse. | |
| 87 | 136 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 88 | 137 | pub enum BillingInterval { | |
| 89 | 138 | Monthly, | |
| 90 | 139 | Annual, | |
| 91 | 140 | } | |
| 92 | 141 | ||
| 142 | + | impl Serialize for BillingInterval { | |
| 143 | + | fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> { | |
| 144 | + | serializer.serialize_str(self.as_str()) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | impl<'de> Deserialize<'de> for BillingInterval { | |
| 149 | + | fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> { | |
| 150 | + | let s = String::deserialize(deserializer)?; | |
| 151 | + | Ok(BillingInterval::from_wire(&s)) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 93 | 155 | impl BillingInterval { | |
| 94 | 156 | pub fn as_str(self) -> &'static str { | |
| 95 | 157 | match self { | |
| @@ -137,8 +199,8 @@ pub struct AccountInfo { | |||
| 137 | 199 | #[non_exhaustive] | |
| 138 | 200 | pub struct PriceQuote { | |
| 139 | 201 | pub cap_bytes: i64, | |
| 140 | - | pub interval: String, | |
| 141 | - | pub price_cents: i64, | |
| 202 | + | pub interval: BillingInterval, | |
| 203 | + | pub price_cents: Cents, | |
| 142 | 204 | } | |
| 143 | 205 | ||
| 144 | 206 | #[derive(Debug, Serialize)] | |
| @@ -171,7 +233,7 @@ impl SyncKitClient { | |||
| 171 | 233 | let body = Bytes::from(serde_json::to_vec(&AppPricingRequest { | |
| 172 | 234 | api_key: &self.config.api_key, | |
| 173 | 235 | })?); | |
| 174 | - | self.retry_request_json(|| { | |
| 236 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 175 | 237 | let req = self | |
| 176 | 238 | .http | |
| 177 | 239 | .post(&self.endpoints.app_pricing) | |
| @@ -191,7 +253,7 @@ impl SyncKitClient { | |||
| 191 | 253 | cap_bytes, | |
| 192 | 254 | interval: interval.as_str(), | |
| 193 | 255 | })?); | |
| 194 | - | self.retry_request_json(|| { | |
| 256 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 195 | 257 | let req = self | |
| 196 | 258 | .http | |
| 197 | 259 | .post(&self.endpoints.subscription_quote) | |
| @@ -208,7 +270,7 @@ impl SyncKitClient { | |||
| 208 | 270 | /// Used by apps to display "logged in as ..." in their cloud-sync UI. | |
| 209 | 271 | pub async fn get_account_info(&self) -> Result<AccountInfo> { | |
| 210 | 272 | let token = self.require_token()?; | |
| 211 | - | self.retry_request_json(|| { | |
| 273 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 212 | 274 | let req = self.http.get(&self.endpoints.account).bearer_auth(token.as_str()); | |
| 213 | 275 | async move { check_response(req.send().await?).await } | |
| 214 | 276 | }) | |
| @@ -221,7 +283,7 @@ impl SyncKitClient { | |||
| 221 | 283 | /// or `active: false` if the user needs to subscribe. | |
| 222 | 284 | pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> { | |
| 223 | 285 | let token = self.require_token()?; | |
| 224 | - | self.retry_request_json(|| { | |
| 286 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 225 | 287 | let req = self.http.get(&self.endpoints.subscription).bearer_auth(token.as_str()); | |
| 226 | 288 | async move { check_response(req.send().await?).await } | |
| 227 | 289 | }) | |
| @@ -241,7 +303,7 @@ impl SyncKitClient { | |||
| 241 | 303 | cap_bytes, | |
| 242 | 304 | interval: interval.as_str(), | |
| 243 | 305 | })?); | |
| 244 | - | self.retry_request_json(|| { | |
| 306 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 245 | 307 | let req = self | |
| 246 | 308 | .http | |
| 247 | 309 | .post(&self.endpoints.subscription_checkout) | |
| @@ -259,7 +321,7 @@ impl SyncKitClient { | |||
| 259 | 321 | pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> { | |
| 260 | 322 | let token = self.require_token()?; | |
| 261 | 323 | let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?); | |
| 262 | - | self.retry_request_json(|| { | |
| 324 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 263 | 325 | let req = self | |
| 264 | 326 | .http | |
| 265 | 327 | .post(&self.endpoints.subscription_storage_cap) | |
| @@ -279,7 +341,7 @@ mod tests { | |||
| 279 | 341 | fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing { | |
| 280 | 342 | AppPricing { | |
| 281 | 343 | app_name: "test".into(), | |
| 282 | - | min_charge_cents: min_charge, | |
| 344 | + | min_charge_cents: Cents(min_charge), | |
| 283 | 345 | per_gb_tenths_of_cent_per_month: per_gb_tenths, | |
| 284 | 346 | annual_multiplier: annual_mult, | |
| 285 | 347 | min_cap_bytes: 0, | |
| @@ -293,10 +355,10 @@ mod tests { | |||
| 293 | 355 | // 16-cent floor; annual = 10x. | |
| 294 | 356 | let p = pricing(16, 50, 10); | |
| 295 | 357 | let ten_gib = 10 * 1024 * 1024 * 1024; | |
| 296 | - | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), 50); | |
| 297 | - | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), 500); | |
| 358 | + | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50)); | |
| 359 | + | assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500)); | |
| 298 | 360 | // Tiny cap falls back to the minimum charge. | |
| 299 | - | assert_eq!(p.quote_cents(1, BillingInterval::Monthly), 16); | |
| 361 | + | assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16)); | |
| 300 | 362 | } | |
| 301 | 363 | ||
| 302 | 364 | #[test] | |
| @@ -305,8 +367,8 @@ mod tests { | |||
| 305 | 367 | // (debug) or wrap negative (release) — it saturates. | |
| 306 | 368 | let p = pricing(0, i64::MAX, i64::MAX); | |
| 307 | 369 | let big = p.quote_cents(i64::MAX, BillingInterval::Annual); | |
| 308 | - | assert!(big >= 0, "price must never wrap negative, got {big}"); | |
| 309 | - | assert_eq!(big, i64::MAX); | |
| 370 | + | assert!(big.get() >= 0, "price must never wrap negative, got {big}"); | |
| 371 | + | assert_eq!(big, Cents(i64::MAX)); | |
| 310 | 372 | } | |
| 311 | 373 | ||
| 312 | 374 | #[test] | |
| @@ -316,4 +378,25 @@ mod tests { | |||
| 316 | 378 | assert_eq!(BillingInterval::from_wire("weekly"), BillingInterval::Monthly); | |
| 317 | 379 | assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly); | |
| 318 | 380 | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn billing_interval_serde_roundtrips_as_wire_string() { | |
| 384 | + | // Serializes to the bare wire string and parses back leniently. | |
| 385 | + | assert_eq!(serde_json::to_string(&BillingInterval::Annual).unwrap(), "\"annual\""); | |
| 386 | + | assert_eq!( | |
| 387 | + | serde_json::from_str::<BillingInterval>("\"monthly\"").unwrap(), | |
| 388 | + | BillingInterval::Monthly | |
| 389 | + | ); | |
| 390 | + | // Unknown future tag falls back to Monthly, not a parse error. | |
| 391 | + | assert_eq!( | |
| 392 | + | serde_json::from_str::<BillingInterval>("\"weekly\"").unwrap(), | |
| 393 | + | BillingInterval::Monthly | |
| 394 | + | ); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn cents_is_wire_transparent_to_i64() { | |
| 399 | + | assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234"); | |
| 400 | + | assert_eq!(serde_json::from_str::<Cents>("1234").unwrap(), Cents(1234)); | |
| 401 | + | } | |
| 319 | 402 | } |
| @@ -10,7 +10,7 @@ use crate::{ | |||
| 10 | 10 | }; | |
| 11 | 11 | ||
| 12 | 12 | use super::SyncKitClient; | |
| 13 | - | use super::helpers::check_response; | |
| 13 | + | use super::helpers::{check_response, Idempotency}; | |
| 14 | 14 | ||
| 15 | 15 | impl SyncKitClient { | |
| 16 | 16 | // ── Devices ── | |
| @@ -33,7 +33,7 @@ impl SyncKitClient { | |||
| 33 | 33 | platform: platform.to_string(), | |
| 34 | 34 | })?); | |
| 35 | 35 | ||
| 36 | - | self.retry_request_json(|| { | |
| 36 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 37 | 37 | let req = self | |
| 38 | 38 | .http | |
| 39 | 39 | .post(&self.endpoints.devices) | |
| @@ -50,7 +50,7 @@ impl SyncKitClient { | |||
| 50 | 50 | pub async fn list_devices(&self) -> Result<Vec<Device>> { | |
| 51 | 51 | let token = self.require_token()?; | |
| 52 | 52 | ||
| 53 | - | self.retry_request_json(|| { | |
| 53 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 54 | 54 | let req = self.http.get(&self.endpoints.devices).bearer_auth(&token); | |
| 55 | 55 | async move { check_response(req.send().await?).await } | |
| 56 | 56 | }) | |
| @@ -91,7 +91,7 @@ impl SyncKitClient { | |||
| 91 | 91 | })?); | |
| 92 | 92 | ||
| 93 | 93 | let push_resp: PushResponse = self | |
| 94 | - | .retry_request_json(|| { | |
| 94 | + | .retry_request_json(Idempotency::Keyed, || { | |
| 95 | 95 | let req = self | |
| 96 | 96 | .http | |
| 97 | 97 | .post(&self.endpoints.push) | |
| @@ -198,7 +198,7 @@ impl SyncKitClient { | |||
| 198 | 198 | let token = self.require_token()?; | |
| 199 | 199 | ||
| 200 | 200 | let pull_resp: PullResponse = self | |
| 201 | - | .retry_request_json(|| { | |
| 201 | + | .retry_request_json(Idempotency::Safe, || { | |
| 202 | 202 | let req = self | |
| 203 | 203 | .http | |
| 204 | 204 | .post(&self.endpoints.pull) | |
| @@ -254,7 +254,7 @@ impl SyncKitClient { | |||
| 254 | 254 | pub async fn status(&self) -> Result<SyncStatus> { | |
| 255 | 255 | let token = self.require_token()?; | |
| 256 | 256 | ||
| 257 | - | self.retry_request_json(|| { | |
| 257 | + | self.retry_request_json(Idempotency::Safe, || { | |
| 258 | 258 | let req = self.http.get(&self.endpoints.status).bearer_auth(&token); | |
| 259 | 259 | async move { check_response(req.send().await?).await } | |
| 260 | 260 | }) |
| @@ -57,7 +57,8 @@ pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, Sy | |||
| 57 | 57 | pub use client::{OtaArtifactUpload, OtaManifest, OtaRelease}; | |
| 58 | 58 | pub use oauth::{generate_oauth_state, generate_pkce, states_match, Pkce}; | |
| 59 | 59 | pub use client::subscription::{ | |
| 60 | - | AccountInfo, AppPricing, BillingInterval, CheckoutResponse, PriceQuote, SubscriptionStatus, | |
| 60 | + | AccountInfo, AppPricing, BillingInterval, Cents, CheckoutResponse, PriceQuote, | |
| 61 | + | SubscriptionStatus, | |
| 61 | 62 | }; | |
| 62 | 63 | pub use conflict::{ | |
| 63 | 64 | detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution, |