use crate::error::ErrorKind;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case", tag = "outcome")]
pub enum SyncOutcome {
Updated,
NotModified,
Failed {
kind: ErrorKind,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum ThrottleReason {
Quota,
ServerInterval,
RateLimited,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SyncState {
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub last_attempt: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub last_success: Option<OffsetDateTime>,
pub last_outcome: Option<SyncOutcome>,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub failure_streak: u32,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub not_before: Option<OffsetDateTime>,
pub throttle_reason: Option<ThrottleReason>,
}
impl SyncState {
pub fn record(&mut self, at: OffsetDateTime, outcome: SyncOutcome) {
self.last_attempt = Some(at);
match &outcome {
SyncOutcome::Updated | SyncOutcome::NotModified => {
self.last_success = Some(at);
self.failure_streak = 0;
}
SyncOutcome::Failed { .. } => {
self.failure_streak = self.failure_streak.saturating_add(1);
}
}
self.last_outcome = Some(outcome);
}
pub fn is_failing(&self) -> bool {
self.failure_streak > 0
}
pub fn throttle_until(&mut self, until: OffsetDateTime, reason: ThrottleReason) {
self.not_before = Some(until);
self.throttle_reason = Some(reason);
}
pub fn clear_throttle(&mut self) {
self.not_before = None;
self.throttle_reason = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn a_not_modified_response_still_counts_as_success() {
let mut state = SyncState::default();
state.record(
datetime!(2026-08-23 10:00 UTC),
SyncOutcome::Failed {
kind: ErrorKind::Network,
message: "timeout".into(),
},
);
assert_eq!(state.failure_streak, 1);
state.record(datetime!(2026-08-23 10:05 UTC), SyncOutcome::NotModified);
assert_eq!(state.failure_streak, 0);
assert_eq!(state.last_success, Some(datetime!(2026-08-23 10:05 UTC)));
}
#[test]
fn a_throttle_round_trips_and_can_be_cleared() {
let mut state = SyncState::default();
let until = datetime!(2026-08-23 10:30 UTC);
state.throttle_until(until, ThrottleReason::Quota);
assert_eq!(state.not_before, Some(until));
assert_eq!(state.throttle_reason, Some(ThrottleReason::Quota));
assert!(state.not_before.is_some());
state.clear_throttle();
assert_eq!(state.not_before, None);
assert_eq!(state.throttle_reason, None);
}
}