Skip to main content

origin_domain/
sync.rs

1//! Synchronisation bookkeeping.
2//!
3//! The connector decides *how* to fetch. This type records what happened, so the
4//! platform can decide *when* to try again.
5
6use 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    /// New data was fetched and stored.
15    Updated,
16    /// The service reported no change (ETag / Last-Modified hit).
17    NotModified,
18    Failed {
19        kind: ErrorKind,
20        message: String,
21    },
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
26pub struct SyncState {
27    #[serde(with = "time::serde::rfc3339::option")]
28    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
29    pub last_attempt: Option<OffsetDateTime>,
30    #[serde(with = "time::serde::rfc3339::option")]
31    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
32    pub last_success: Option<OffsetDateTime>,
33    pub last_outcome: Option<SyncOutcome>,
34    /// Validators handed back to the service on the next request.
35    pub etag: Option<String>,
36    pub last_modified: Option<String>,
37    /// Consecutive failures, used for exponential backoff.
38    pub failure_streak: u32,
39}
40
41impl SyncState {
42    pub fn record(&mut self, at: OffsetDateTime, outcome: SyncOutcome) {
43        self.last_attempt = Some(at);
44        match &outcome {
45            SyncOutcome::Updated | SyncOutcome::NotModified => {
46                self.last_success = Some(at);
47                self.failure_streak = 0;
48            }
49            SyncOutcome::Failed { .. } => {
50                self.failure_streak = self.failure_streak.saturating_add(1);
51            }
52        }
53        self.last_outcome = Some(outcome);
54    }
55
56    pub fn is_failing(&self) -> bool {
57        self.failure_streak > 0
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use time::macros::datetime;
65
66    #[test]
67    fn a_not_modified_response_still_counts_as_success() {
68        let mut state = SyncState::default();
69        state.record(
70            datetime!(2026-08-23 10:00 UTC),
71            SyncOutcome::Failed {
72                kind: ErrorKind::Network,
73                message: "timeout".into(),
74            },
75        );
76        assert_eq!(state.failure_streak, 1);
77
78        state.record(datetime!(2026-08-23 10:05 UTC), SyncOutcome::NotModified);
79
80        assert_eq!(state.failure_streak, 0);
81        assert_eq!(state.last_success, Some(datetime!(2026-08-23 10:05 UTC)));
82    }
83}