| 13 |
13 |
|
/// Retry replays the request, so it is only safe when the server treats the
|
| 14 |
14 |
|
/// operation idempotently. Requiring this argument makes that judgement an
|
| 15 |
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.
|
|
16 |
+ |
/// helper without naming why replay is harmless here — and the judgement now has
|
|
17 |
+ |
/// runtime teeth, not just documentary value: [`may_retry`](Self::may_retry)
|
|
18 |
+ |
/// gates the retry loop, so an [`Unsafe`](Self::Unsafe) operation physically
|
|
19 |
+ |
/// cannot be auto-replayed.
|
| 19 |
20 |
|
#[derive(Clone, Copy, Debug)]
|
| 20 |
21 |
|
pub(super) enum Idempotency {
|
| 21 |
22 |
|
/// Read-only, or a write whose replay yields the same end state (a `GET`,
|
| 24 |
25 |
|
/// Carries a client-generated idempotency key (e.g. push's `batch_id`) so
|
| 25 |
26 |
|
/// the server collapses duplicate deliveries into one effect.
|
| 26 |
27 |
|
Keyed,
|
|
28 |
+ |
/// Not idempotent and not idempotency-keyed: replaying it would mint
|
|
29 |
+ |
/// duplicate external state (e.g. a second Stripe Checkout session). The
|
|
30 |
+ |
/// retry helpers attempt it exactly once.
|
|
31 |
+ |
Unsafe,
|
|
32 |
+ |
}
|
|
33 |
+ |
|
|
34 |
+ |
impl Idempotency {
|
|
35 |
+ |
/// Whether the retry helpers may replay this operation on a transient error.
|
|
36 |
+ |
fn may_retry(self) -> bool {
|
|
37 |
+ |
matches!(self, Idempotency::Safe | Idempotency::Keyed)
|
|
38 |
+ |
}
|
| 27 |
39 |
|
}
|
| 28 |
40 |
|
|
| 29 |
41 |
|
/// Current on-wire HLC-envelope version, written into the `__skver` tag.
|
| 69 |
81 |
|
F: FnMut() -> Fut,
|
| 70 |
82 |
|
Fut: std::future::Future<Output = Result<reqwest::Response>>,
|
| 71 |
83 |
|
{
|
| 72 |
|
- |
let _ = idempotency;
|
|
84 |
+ |
// A non-idempotent operation gets exactly one attempt: zero retries.
|
|
85 |
+ |
let max_attempts = if idempotency.may_retry() { MAX_RETRIES } else { 0 };
|
| 73 |
86 |
|
let mut last_err = None;
|
| 74 |
87 |
|
|
| 75 |
|
- |
for attempt in 0..=MAX_RETRIES {
|
|
88 |
+ |
for attempt in 0..=max_attempts {
|
| 76 |
89 |
|
match operation().await {
|
| 77 |
90 |
|
Ok(resp) => return Ok(resp),
|
| 78 |
91 |
|
Err(err) => {
|
| 80 |
93 |
|
return Err(err);
|
| 81 |
94 |
|
}
|
| 82 |
95 |
|
|
| 83 |
|
- |
if attempt < MAX_RETRIES {
|
|
96 |
+ |
if attempt < max_attempts {
|
| 84 |
97 |
|
let delay = retry_delay(&err, attempt);
|
| 85 |
98 |
|
tracing::debug!(
|
| 86 |
99 |
|
attempt = attempt + 1,
|
| 116 |
129 |
|
Fut: std::future::Future<Output = Result<reqwest::Response>>,
|
| 117 |
130 |
|
T: serde::de::DeserializeOwned,
|
| 118 |
131 |
|
{
|
| 119 |
|
- |
let _ = idempotency;
|
|
132 |
+ |
// A non-idempotent operation gets exactly one attempt: zero retries.
|
|
133 |
+ |
let max_attempts = if idempotency.may_retry() { MAX_RETRIES } else { 0 };
|
| 120 |
134 |
|
let mut last_err = None;
|
| 121 |
135 |
|
|
| 122 |
|
- |
for attempt in 0..=MAX_RETRIES {
|
|
136 |
+ |
for attempt in 0..=max_attempts {
|
| 123 |
137 |
|
match operation().await {
|
| 124 |
138 |
|
Ok(resp) => {
|
| 125 |
139 |
|
match resp.json::<T>().await {
|
| 126 |
140 |
|
Ok(parsed) => return Ok(parsed),
|
| 127 |
141 |
|
Err(e) => {
|
| 128 |
142 |
|
let err = SyncKitError::Http(e);
|
| 129 |
|
- |
if attempt < MAX_RETRIES {
|
|
143 |
+ |
if attempt < max_attempts {
|
| 130 |
144 |
|
let delay = retry_delay(&err, attempt);
|
| 131 |
145 |
|
tracing::debug!(
|
| 132 |
146 |
|
attempt = attempt + 1,
|
| 146 |
160 |
|
return Err(err);
|
| 147 |
161 |
|
}
|
| 148 |
162 |
|
|
| 149 |
|
- |
if attempt < MAX_RETRIES {
|
|
163 |
+ |
if attempt < max_attempts {
|
| 150 |
164 |
|
let delay = retry_delay(&err, attempt);
|
| 151 |
165 |
|
tracing::debug!(
|
| 152 |
166 |
|
attempt = attempt + 1,
|
| 153 |
|
- |
max_retries = MAX_RETRIES,
|
|
167 |
+ |
max_retries = max_attempts,
|
| 154 |
168 |
|
delay_ms = delay.as_millis() as u64,
|
| 155 |
169 |
|
error = %err,
|
| 156 |
170 |
|
"Transient error, retrying after backoff",
|
| 295 |
309 |
|
})
|
| 296 |
310 |
|
}
|
| 297 |
311 |
|
|
| 298 |
|
- |
/// Decrypt with key selection based on key_id. Used during rotation when
|
| 299 |
|
- |
/// pulled entries may be encrypted with different keys.
|
| 300 |
|
- |
#[allow(dead_code)]
|
| 301 |
|
- |
pub(super) fn decrypt_change_multi_key(
|
|
312 |
+ |
/// Decrypt a pulled entry during a rotation window, selecting the key by the
|
|
313 |
+ |
/// entry's `key_id`. Generic over the decrypt step so both the plain-pull
|
|
314 |
+ |
/// (`ChangeEntry`) and rich-pull (`PulledChange`) paths share exactly this
|
|
315 |
+ |
/// selection logic — including the unknown-`key_id` fallback that tries the
|
|
316 |
+ |
/// primary key then the pending key. Previously the live pull path
|
|
317 |
+ |
/// reimplemented a fallback-less variant while the tested one sat unused.
|
|
318 |
+ |
pub(super) fn decrypt_with_rotation_keys<T, F>(
|
| 302 |
319 |
|
entry: PullChangeEntry,
|
| 303 |
320 |
|
primary_key: &[u8; 32],
|
| 304 |
321 |
|
primary_key_id: i32,
|
| 305 |
322 |
|
pending_key: &[u8; 32],
|
| 306 |
323 |
|
pending_key_id: i32,
|
| 307 |
|
- |
) -> Result<ChangeEntry> {
|
|
324 |
+ |
decrypt_fn: &F,
|
|
325 |
+ |
) -> Result<T>
|
|
326 |
+ |
where
|
|
327 |
+ |
F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
|
|
328 |
+ |
{
|
| 308 |
329 |
|
let effective_key_id = entry.key_id.unwrap_or(1);
|
| 309 |
|
- |
let key = if effective_key_id == pending_key_id {
|
| 310 |
|
- |
pending_key
|
|
330 |
+ |
if effective_key_id == pending_key_id {
|
|
331 |
+ |
decrypt_fn(entry, pending_key)
|
| 311 |
332 |
|
} else if effective_key_id == primary_key_id || effective_key_id <= 1 {
|
| 312 |
|
- |
primary_key
|
|
333 |
+ |
decrypt_fn(entry, primary_key)
|
| 313 |
334 |
|
} else {
|
| 314 |
|
- |
// Unknown key_id — try primary, then pending
|
| 315 |
|
- |
match Self::decrypt_change_with_key_inner(&entry, primary_key) {
|
| 316 |
|
- |
Ok(result) => return Ok(result),
|
| 317 |
|
- |
Err(_) => pending_key,
|
| 318 |
|
- |
}
|
| 319 |
|
- |
};
|
| 320 |
|
- |
Self::decrypt_change_with_key(entry, key)
|
| 321 |
|
- |
}
|
| 322 |
|
- |
|
| 323 |
|
- |
/// Inner decryption that borrows the entry (for fallback logic).
|
| 324 |
|
- |
#[allow(dead_code)]
|
| 325 |
|
- |
fn decrypt_change_with_key_inner(entry: &PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
|
| 326 |
|
- |
let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
|
| 327 |
|
- |
let (hlc, data) = match entry.data {
|
| 328 |
|
- |
Some(ref value) => {
|
| 329 |
|
- |
let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?;
|
| 330 |
|
- |
Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())?
|
|
335 |
+ |
// Unknown key_id — try primary, then fall back to pending.
|
|
336 |
+ |
match decrypt_fn(entry.clone(), primary_key) {
|
|
337 |
+ |
Ok(result) => Ok(result),
|
|
338 |
+ |
Err(_) => decrypt_fn(entry, pending_key),
|
| 331 |
339 |
|
}
|
| 332 |
|
- |
None => (
|
| 333 |
|
- |
Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()),
|
| 334 |
|
- |
None,
|
| 335 |
|
- |
),
|
| 336 |
|
- |
};
|
| 337 |
|
- |
|
| 338 |
|
- |
Ok(ChangeEntry {
|
| 339 |
|
- |
table: entry.table.clone(),
|
| 340 |
|
- |
op: entry.op,
|
| 341 |
|
- |
row_id: entry.row_id.clone(),
|
| 342 |
|
- |
hlc,
|
| 343 |
|
- |
timestamp: entry.timestamp,
|
| 344 |
|
- |
data,
|
| 345 |
|
- |
extra: Default::default(),
|
| 346 |
|
- |
})
|
|
340 |
+ |
}
|
| 347 |
341 |
|
}
|
| 348 |
342 |
|
|
| 349 |
343 |
|
/// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
|
| 1084 |
1078 |
|
let plaintext = serde_json::json!({"v": "pending-payload"});
|
| 1085 |
1079 |
|
let entry = pull_entry_with(encrypt_with(&pending, plaintext.clone()), Some(7));
|
| 1086 |
1080 |
|
|
| 1087 |
|
- |
let decrypted = SyncKitClient::decrypt_change_multi_key(entry, &primary, 1, &pending, 7).unwrap();
|
|
1081 |
+ |
let decrypted = SyncKitClient::decrypt_with_rotation_keys(entry, &primary, 1, &pending, 7, &SyncKitClient::decrypt_change_with_key).unwrap();
|
| 1088 |
1082 |
|
assert_eq!(decrypted.data.unwrap(), plaintext);
|
| 1089 |
1083 |
|
}
|
| 1090 |
1084 |
|
|
| 1095 |
1089 |
|
let plaintext = serde_json::json!({"v": "primary-payload"});
|
| 1096 |
1090 |
|
let entry = pull_entry_with(encrypt_with(&primary, plaintext.clone()), Some(3));
|
| 1097 |
1091 |
|
|
| 1098 |
|
- |
let decrypted = SyncKitClient::decrypt_change_multi_key(entry, &primary, 3, &pending, 9).unwrap();
|
|
1092 |
+ |
let decrypted = SyncKitClient::decrypt_with_rotation_keys(entry, &primary, 3, &pending, 9, &SyncKitClient::decrypt_change_with_key).unwrap();
|
| 1099 |
1093 |
|
assert_eq!(decrypted.data.unwrap(), plaintext);
|
| 1100 |
1094 |
|
}
|
| 1101 |
1095 |
|
|
| 1111 |
1105 |
|
let plaintext = serde_json::json!({"v": "legacy-no-key-id"});
|
| 1112 |
1106 |
|
let entry = pull_entry_with(encrypt_with(&primary, plaintext.clone()), None);
|
| 1113 |
1107 |
|
|
| 1114 |
|
- |
let decrypted = SyncKitClient::decrypt_change_multi_key(entry, &primary, 5, &pending, 6).unwrap();
|
|
1108 |
+ |
let decrypted = SyncKitClient::decrypt_with_rotation_keys(entry, &primary, 5, &pending, 6, &SyncKitClient::decrypt_change_with_key).unwrap();
|
| 1115 |
1109 |
|
assert_eq!(decrypted.data.unwrap(), plaintext);
|
| 1116 |
1110 |
|
}
|
| 1117 |
1111 |
|
|
| 1125 |
1119 |
|
let plaintext = serde_json::json!({"v": "via-fallback-primary"});
|
| 1126 |
1120 |
|
let entry = pull_entry_with(encrypt_with(&primary, plaintext.clone()), Some(42));
|
| 1127 |
1121 |
|
|
| 1128 |
|
- |
let decrypted = SyncKitClient::decrypt_change_multi_key(entry, &primary, 5, &pending, 6).unwrap();
|
|
1122 |
+ |
let decrypted = SyncKitClient::decrypt_with_rotation_keys(entry, &primary, 5, &pending, 6, &SyncKitClient::decrypt_change_with_key).unwrap();
|
| 1129 |
1123 |
|
assert_eq!(decrypted.data.unwrap(), plaintext);
|
| 1130 |
1124 |
|
}
|
| 1131 |
1125 |
|
|
| 1138 |
1132 |
|
let plaintext = serde_json::json!({"v": "via-fallback-pending"});
|
| 1139 |
1133 |
|
let entry = pull_entry_with(encrypt_with(&pending, plaintext.clone()), Some(42));
|
| 1140 |
1134 |
|
|
| 1141 |
|
- |
let decrypted = SyncKitClient::decrypt_change_multi_key(entry, &primary, 5, &pending, 6).unwrap();
|
|
1135 |
+ |
let decrypted = SyncKitClient::decrypt_with_rotation_keys(entry, &primary, 5, &pending, 6, &SyncKitClient::decrypt_change_with_key).unwrap();
|
| 1142 |
1136 |
|
assert_eq!(decrypted.data.unwrap(), plaintext);
|
| 1143 |
1137 |
|
}
|
| 1144 |
1138 |
|
}
|