Skip to main content

max / makenotwork

synckit: Cents money newtype + typed BillingInterval + retry-idempotency proof Deep A+ pass, type-foundation axis (cont). Cents(i64) centralizes the saturating pricing arithmetic and types AppPricing/PriceQuote money fields; BillingInterval gains wire-string serde so SubscriptionStatus/PriceQuote carry the enum, not a String. Both are #[serde(transparent)]/string-valued, so the wire and JSON are unchanged. Retry helpers now require an Idempotency proof at the call site (Safe vs Keyed) so a future non-idempotent endpoint cannot silently inherit replay; push declares Keyed (batch_id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 22:26 UTC
Commit: 993520f0fbe34dc33b5738754553846d9f4d44f1
Parent: 150f0c2
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,