rovenue/transport/
idempotency.rs

1/// An opaque key the SDK attaches via the `Idempotency-Key` header.
2/// Reused across all retry attempts of the same logical call — that's how
3/// the server's 24h dedup window discriminates "this is a retry" from
4/// "this is a new request."
5#[derive(Debug, Clone)]
6pub struct IdempotencyKey(String);
7
8/// FNV-1a 64-bit. Dependency-free, deterministic across runs of the same binary.
9/// Cryptographic strength is unnecessary: a collision only affects the server's
10/// 24h response cache, and DB-level dedup guarantees correctness regardless.
11fn fnv1a_64(bytes: &[u8]) -> u64 {
12    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
13    for b in bytes {
14        hash ^= *b as u64;
15        hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
16    }
17    hash
18}
19
20impl IdempotencyKey {
21    pub fn new() -> Self {
22        Self(format!("idem_{}", cuid2::create_id()))
23    }
24
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28
29    /// Deterministic key for a receipt POST, derived from the store + receipt
30    /// token. All posts of the same transaction (first send, reconcile re-post,
31    /// StoreKit re-delivery) share one key, so the server replays its cached
32    /// response within the 24h window instead of re-verifying with the store.
33    pub fn for_receipt(store: &str, receipt: &str) -> Self {
34        let mut input = String::with_capacity(store.len() + 1 + receipt.len());
35        input.push_str(store);
36        input.push(':');
37        input.push_str(receipt);
38        Self(format!("idem_rcpt_{:016x}", fnv1a_64(input.as_bytes())))
39    }
40}
41
42impl Default for IdempotencyKey {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn for_receipt_is_deterministic_and_scoped() {
54        let a = IdempotencyKey::for_receipt("apple", "jws-token-xyz");
55        let b = IdempotencyKey::for_receipt("apple", "jws-token-xyz");
56        assert_eq!(a.as_str(), b.as_str(), "same input must yield same key");
57
58        let diff_receipt = IdempotencyKey::for_receipt("apple", "jws-token-zzz");
59        assert_ne!(a.as_str(), diff_receipt.as_str());
60
61        let diff_store = IdempotencyKey::for_receipt("google", "jws-token-xyz");
62        assert_ne!(a.as_str(), diff_store.as_str());
63
64        assert!(a.as_str().starts_with("idem_rcpt_"));
65    }
66}