1use crate::error::ErrorKind;
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
12#[serde(rename_all = "snake_case", tag = "outcome")]
13pub enum SyncOutcome {
14 Updated,
16 NotModified,
18 Failed {
19 kind: ErrorKind,
20 message: String,
21 },
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31#[serde(rename_all = "snake_case")]
32pub enum ThrottleReason {
33 Quota,
36 ServerInterval,
38 RateLimited,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
44pub struct SyncState {
45 #[serde(with = "time::serde::rfc3339::option")]
46 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
47 pub last_attempt: Option<OffsetDateTime>,
48 #[serde(with = "time::serde::rfc3339::option")]
49 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
50 pub last_success: Option<OffsetDateTime>,
51 pub last_outcome: Option<SyncOutcome>,
52 pub etag: Option<String>,
54 pub last_modified: Option<String>,
55 pub failure_streak: u32,
57 #[serde(with = "time::serde::rfc3339::option")]
61 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
62 pub not_before: Option<OffsetDateTime>,
63 pub throttle_reason: Option<ThrottleReason>,
65}
66
67impl SyncState {
68 pub fn record(&mut self, at: OffsetDateTime, outcome: SyncOutcome) {
69 self.last_attempt = Some(at);
70 match &outcome {
71 SyncOutcome::Updated | SyncOutcome::NotModified => {
72 self.last_success = Some(at);
73 self.failure_streak = 0;
74 }
75 SyncOutcome::Failed { .. } => {
76 self.failure_streak = self.failure_streak.saturating_add(1);
77 }
78 }
79 self.last_outcome = Some(outcome);
80 }
81
82 pub fn is_failing(&self) -> bool {
83 self.failure_streak > 0
84 }
85
86 pub fn throttle_until(&mut self, until: OffsetDateTime, reason: ThrottleReason) {
88 self.not_before = Some(until);
89 self.throttle_reason = Some(reason);
90 }
91
92 pub fn clear_throttle(&mut self) {
94 self.not_before = None;
95 self.throttle_reason = None;
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use time::macros::datetime;
103
104 #[test]
105 fn a_not_modified_response_still_counts_as_success() {
106 let mut state = SyncState::default();
107 state.record(
108 datetime!(2026-08-23 10:00 UTC),
109 SyncOutcome::Failed {
110 kind: ErrorKind::Network,
111 message: "timeout".into(),
112 },
113 );
114 assert_eq!(state.failure_streak, 1);
115
116 state.record(datetime!(2026-08-23 10:05 UTC), SyncOutcome::NotModified);
117
118 assert_eq!(state.failure_streak, 0);
119 assert_eq!(state.last_success, Some(datetime!(2026-08-23 10:05 UTC)));
120 }
121
122 #[test]
123 fn a_throttle_round_trips_and_can_be_cleared() {
124 let mut state = SyncState::default();
125 let until = datetime!(2026-08-23 10:30 UTC);
126
127 state.throttle_until(until, ThrottleReason::Quota);
128 assert_eq!(state.not_before, Some(until));
129 assert_eq!(state.throttle_reason, Some(ThrottleReason::Quota));
130 assert!(state.not_before.is_some());
131
132 state.clear_throttle();
133 assert_eq!(state.not_before, None);
134 assert_eq!(state.throttle_reason, None);
135 }
136}